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]
531pub fn named_stack(idea: &str, installed: &[(Stack, String)]) -> Option<StackPick> {
532 cued(idea, installed, true)
533}
534
535#[must_use]
540pub fn suggest_stack(idea: &str, installed: &[(Stack, String)]) -> StackPick {
541 if let Some(p) = cued(idea, installed, true).or_else(|| cued(idea, installed, false)) {
542 return p;
543 }
544 let version = |s: Stack| installed.iter().find(|(k, _)| *k == s);
545 match Stack::ALL.into_iter().find(|s| version(*s).is_some()) {
546 Some(s) => StackPick {
547 id: s,
548 detected: false,
549 reason: format!(
550 "{} — nothing in the idea names a stack",
551 tool_line(installed, s)
552 ),
553 },
554 None => StackPick {
555 id: Stack::None,
556 detected: false,
557 reason: "no stack tool is installed: add code yourself".to_owned(),
558 },
559 }
560}
561
562fn tool_line(installed: &[(Stack, String)], s: Stack) -> String {
563 let tool = s.tool().unwrap_or("");
564 match installed
565 .iter()
566 .find(|(k, _)| *k == s)
567 .map(|(_, v)| v.as_str())
568 {
569 Some(v) if !v.is_empty() => format!("{tool} {v} is installed"),
570 _ => format!("{tool} is installed"),
571 }
572}
573
574fn cued(idea: &str, installed: &[(Stack, String)], named: bool) -> Option<StackPick> {
577 let lower = ascii_fold(idea).to_ascii_lowercase();
578 let words: Vec<&str> = lower
579 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
580 .filter(|w| !w.is_empty())
581 .collect();
582 for (stack, cues) in CUES {
583 if !installed.iter().any(|(k, _)| k == stack) {
584 continue;
585 }
586 let pool: &[&str] = if named {
587 &cues[..cues.len().min(3)]
588 } else {
589 cues
590 };
591 if let Some(w) = words.iter().find(|w| pool.contains(w)) {
592 let shown = if w.len() <= 4 {
593 w.to_ascii_uppercase()
594 } else {
595 (*w).to_owned()
596 };
597 return Some(StackPick {
598 id: *stack,
599 detected: false,
600 reason: format!("\"{shown}\" and {}", tool_line(installed, *stack)),
601 });
602 }
603 }
604 None
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 #[test]
612 fn a_slug_takes_five_significant_words_folded() {
613 assert_eq!(
614 slug("a CLI that turns Markdown tables into CSV"),
615 "cli-turns-markdown-tables-csv"
616 );
617 assert_eq!(slug("Ünïcödé café ☕"), "unicode-cafe");
618 assert_eq!(slug(" "), "project");
619 assert_eq!(slug("the a of"), "project");
620 let long = slug("supercalifragilisticexpialidocious extraordinarily verbose naming");
621 assert!(long.len() <= SLUG_MAX, "{long}");
622 assert_eq!(slug("md2csv"), "md2csv");
623 }
624
625 #[test]
626 fn a_named_language_wins_and_missing_tools_are_never_suggested() {
627 let all = [
628 (Stack::Rust, "1.91.0".to_owned()),
629 (Stack::Python, "0.9.0".to_owned()),
630 (Stack::Node, "11.0.0".to_owned()),
631 (Stack::Go, "1.25".to_owned()),
632 ];
633 assert_eq!(
635 named_stack("a CLI that turns Markdown tables into CSV", &all),
636 None
637 );
638 assert_eq!(
639 named_stack("a Python CLI for tables", &all).map(|p| p.id),
640 Some(Stack::Python)
641 );
642 let s = suggest_stack("a CLI that turns Markdown tables into CSV", &all);
643 assert_eq!(s.id, Stack::Rust);
644 assert_eq!(s.reason, "\"CLI\" and cargo 1.91.0 is installed");
645 assert_eq!(
646 suggest_stack("a python script for my photos", &all).id,
647 Stack::Python
648 );
649 assert_eq!(suggest_stack("a web dashboard", &all).id, Stack::Node);
650 assert_eq!(suggest_stack("an API in go", &all).id, Stack::Go);
651 let no_python = [(Stack::Rust, "1.91.0".to_owned())];
652 assert_eq!(suggest_stack("a python script", &no_python).id, Stack::Rust);
653 assert_eq!(suggest_stack("anything", &[]).id, Stack::None);
654 }
655
656 #[test]
657 fn extensions_are_classified_and_urls_name_their_host() {
658 assert_eq!(ExtensionChoice::of("bug").kind, ExtensionKind::Bundled);
659 assert_eq!(
660 ExtensionChoice::of("~/x/threatspec").kind,
661 ExtensionKind::Path
662 );
663 let u = ExtensionChoice::of("https://user@example.com:8443/x.zip");
664 assert_eq!(u.kind, ExtensionKind::Url);
665 assert_eq!(u.host().as_deref(), Some("example.com"));
666 }
667
668 #[test]
669 fn a_plan_lists_its_network_hosts_once() {
670 let step = |id: &str, access, hosts: &[&str]| PlanStep {
671 id: id.to_owned(),
672 argv: vec!["x".to_owned()],
673 cwd: "/t".to_owned(),
674 access,
675 hosts: hosts.iter().map(|h| (*h).to_owned()).collect(),
676 writes: String::new(),
677 limit_s: LOCAL_LIMIT_S,
678 note: None,
679 };
680 let plan = ProjectPlan {
681 target: "/t".to_owned(),
682 name: "t".to_owned(),
683 mode: ProjectMode::NewFolder,
684 stack: StackPick {
685 id: Stack::Python,
686 detected: false,
687 reason: String::new(),
688 },
689 stacks: Vec::new(),
690 format: "speckit".to_owned(),
691 formats: Vec::new(),
692 extensions: Vec::new(),
693 preset: true,
694 steps: vec![
695 step("a", StepAccess::Local, &[]),
696 step("b", StepAccess::Network, &["pypi.org"]),
697 step(
698 "c",
699 StepAccess::Network,
700 &["pypi.org", "files.pythonhosted.org"],
701 ),
702 ],
703 sandbox: SandboxNote { backend: None },
704 identity: None,
705 refusal: None,
706 observed: ObservedFolder::default(),
707 digest: String::new(),
708 };
709 assert_eq!(
710 plan.network_hosts(),
711 vec!["pypi.org", "files.pythonhosted.org"]
712 );
713 }
714}