Skip to main content

speccoder_core/
new_project.rs

1//! A new project's plan: the exact commands that make an empty folder (or
2//! a clean repository) a project speccoder can judge — the stack's own
3//! scaffold, the spec format's own init, `speccoder init --yes` and one
4//! commit as the person — with what each touches, whether it reaches the
5//! network, and the folder as it was seen. The person confirms this plan;
6//! the runtime runs exactly it, bound to its digest.
7
8use serde::{Deserialize, Serialize};
9use unicode_normalization::UnicodeNormalization;
10
11/// A stack the plan can scaffold, or none.
12#[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 with cargo.
28    Rust,
29    /// Python with uv and pytest.
30    Python,
31    /// TypeScript with npm and vitest.
32    Node,
33    /// Go modules.
34    Go,
35    /// No scaffold: the person adds code.
36    None,
37}
38
39impl Stack {
40    /// Every stack, in the sheet's order.
41    pub const ALL: [Self; 5] = [Self::Rust, Self::Python, Self::Node, Self::Go, Self::None];
42
43    /// Its name as serialized (`node`).
44    #[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    /// How the sheet names it.
56    #[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    /// The tool its scaffold runs (none for [`Stack::None`]).
68    #[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    /// Where to install its tool.
80    #[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    /// The stack named `s` (`rust`, `python`, `node`, `go`, `none`).
92    #[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/// Whether a step stays on the machine.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(rename_all = "snake_case")]
101pub enum StepAccess {
102    /// No network.
103    Local,
104    /// It gets the full network (the hosts it names are expected, not enforced).
105    Network,
106}
107
108/// Where the project is made.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
110#[serde(rename_all = "snake_case")]
111pub enum ProjectMode {
112    /// A folder that does not exist yet, or an empty one.
113    NewFolder,
114    /// A git repository at its top level with a clean tree and no spec project.
115    ExistingRepository,
116}
117
118/// A step's state in a run.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
120#[serde(rename_all = "snake_case")]
121pub enum PlanStepState {
122    /// Not started.
123    Waiting,
124    /// Running now.
125    Running,
126    /// Finished.
127    Done,
128    /// Its effect was already there (resume).
129    Skipped,
130    /// Failed; later steps did not run.
131    Failed,
132}
133
134impl PlanStepState {
135    /// The word a person reads.
136    #[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/// A Spec Kit extension the person chose.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
150pub struct ExtensionChoice {
151    /// A bundled name, a local path or a URL, as given.
152    pub source: String,
153    /// `bundled`, `path` or `url`.
154    pub kind: ExtensionKind,
155}
156
157/// What an extension source is.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
159#[serde(rename_all = "snake_case")]
160pub enum ExtensionKind {
161    /// Bundled with the format's tool (`bug`).
162    Bundled,
163    /// A local directory.
164    Path,
165    /// Downloaded: a network step.
166    Url,
167}
168
169impl ExtensionChoice {
170    /// `source` classified: a URL, a path (has a `/` or starts with `.`
171    /// or `~`), else a bundled name.
172    #[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    /// The host a URL extension reaches.
189    #[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/// What the person chose; with the folder, everything the plan follows from.
202#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
203pub struct NewChoices {
204    /// What they are building, verbatim (kept for the first feature).
205    pub idea: String,
206    /// The stack; `None`: the suggestion.
207    pub stack: Option<Stack>,
208    /// The spec format (`speckit`, `openspec`); `None`: Spec Kit.
209    pub format: Option<String>,
210    /// Spec Kit extensions.
211    #[serde(default)]
212    pub extensions: Vec<String>,
213    /// Leave out the speccoder Spec Kit preset.
214    #[serde(default)]
215    pub no_preset: bool,
216}
217
218/// One command of the plan.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
220pub struct PlanStep {
221    /// `git_init`, `scaffold.<n>`, `format_init`, `speccoder_init`, `commit`.
222    pub id: String,
223    /// The command, exactly as it runs.
224    pub argv: Vec<String>,
225    /// Where it runs: always the target.
226    pub cwd: String,
227    /// Local, or network.
228    pub access: StepAccess,
229    /// The hosts a network step is expected to reach: shown, not enforced —
230    /// the step gets the full network.
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub hosts: Vec<String>,
233    /// What it writes, in one line.
234    pub writes: String,
235    /// Its time limit, in seconds.
236    pub limit_s: u32,
237    /// A note the plan shows beside it.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub note: Option<String>,
240}
241
242/// The time limit of a local step, seconds.
243pub const LOCAL_LIMIT_S: u32 = 120;
244/// The time limit of a network step, seconds.
245pub const NETWORK_LIMIT_S: u32 = 600;
246
247/// How a stack was chosen.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
249pub struct StackPick {
250    /// The stack.
251    pub id: Stack,
252    /// Found in an existing repository rather than chosen.
253    pub detected: bool,
254    /// Why it was suggested or detected.
255    pub reason: String,
256}
257
258/// A stack's tool on this machine.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
260pub struct StackOption {
261    /// The stack.
262    pub id: Stack,
263    /// How the sheet names it.
264    pub label: String,
265    /// The tool's version when installed.
266    pub version: Option<String>,
267    /// Why it cannot be chosen (its tool is missing), with the install hint.
268    pub disabled: Option<String>,
269}
270
271/// A spec format the plan can initialise, and its tool's state.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
273pub struct FormatOption {
274    /// `speckit`, `openspec`.
275    pub id: String,
276    /// `Spec Kit`, `OpenSpec`.
277    pub label: String,
278    /// The tool's version when installed.
279    pub version: Option<String>,
280    /// Why it cannot be chosen.
281    pub disabled: Option<String>,
282}
283
284/// The person's git identity.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
286pub struct GitIdentity {
287    /// `user.name`.
288    pub name: String,
289    /// `user.email`.
290    pub email: String,
291}
292
293/// Why the plan cannot run, and the fix.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
295pub struct PlanRefusal {
296    /// `NEW-TARGET-NOT-EMPTY`, …
297    pub code: String,
298    /// What is wrong.
299    pub message: String,
300    /// What to do.
301    pub fix: String,
302}
303
304/// The folder and the tools as the plan saw them.
305#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
306pub struct ObservedFolder {
307    /// The folder exists.
308    pub exists: bool,
309    /// Its entries' names, sorted.
310    pub entries: Vec<String>,
311    /// `HEAD`, in a repository with a commit.
312    pub head: Option<String>,
313    /// `git status --porcelain` lines.
314    pub porcelain: Vec<String>,
315    /// Each tool's version line, by name.
316    pub tools: std::collections::BTreeMap<String, String>,
317}
318
319/// The OS sandbox the steps run in.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
321pub struct SandboxNote {
322    /// `seatbelt`, `landlock`; `null`: the commands run without one.
323    pub backend: Option<String>,
324}
325
326/// The plan.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
328pub struct ProjectPlan {
329    /// The folder, canonical and absolute.
330    pub target: String,
331    /// The project's name (the folder's).
332    pub name: String,
333    /// A new folder or an existing repository.
334    pub mode: ProjectMode,
335    /// The stack.
336    pub stack: StackPick,
337    /// Every stack and whether its tool is installed.
338    pub stacks: Vec<StackOption>,
339    /// The spec format (`speckit`, `openspec`).
340    pub format: String,
341    /// Every format and whether its tool is installed.
342    pub formats: Vec<FormatOption>,
343    /// Spec Kit extensions.
344    pub extensions: Vec<ExtensionChoice>,
345    /// Whether the speccoder Spec Kit preset is passed to the format's init.
346    pub preset: bool,
347    /// The commands, in order.
348    pub steps: Vec<PlanStep>,
349    /// The OS sandbox.
350    pub sandbox: SandboxNote,
351    /// The person's git identity.
352    pub identity: Option<GitIdentity>,
353    /// Why it cannot run (Create disabled).
354    pub refusal: Option<PlanRefusal>,
355    /// The folder as seen.
356    pub observed: ObservedFolder,
357    /// SHA-256 over all of the above.
358    pub digest: String,
359}
360
361impl ProjectPlan {
362    /// The hosts its network steps reach, in order, once each.
363    #[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/// One step's outcome.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
382pub struct PlanStepResult {
383    /// The step.
384    pub id: String,
385    /// What happened.
386    pub state: PlanStepState,
387    /// Its exit status, when it ran.
388    pub exit: Option<i32>,
389    /// How long it took.
390    pub duration_ms: u64,
391    /// The last lines of its output (at most 20).
392    pub output_tail: Vec<String>,
393}
394
395/// What Undo would do.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
397pub struct UndoOffer {
398    /// It can be undone now.
399    pub available: bool,
400    /// `trash`, `revert` or `none`.
401    pub kind: String,
402    /// Why not, when not.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub reason: Option<String>,
405}
406
407/// The `new_project` payload.
408#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
409pub struct NewProjectReport {
410    /// The plan.
411    pub plan: ProjectPlan,
412    /// Each step's outcome (empty on a dry run).
413    pub results: Vec<PlanStepResult>,
414    /// `speccoder init`'s report, when it ran.
415    pub init: Option<crate::InitReport>,
416    /// speccoder's commit, once made.
417    pub commit: Option<String>,
418    /// What Undo would do.
419    pub undo: UndoOffer,
420    /// Nothing ran.
421    pub dry_run: bool,
422    /// The project's first feature, prefilled from the idea.
423    pub first_feature: Option<String>,
424}
425
426/// The ASCII fold of `text`: decomposed, marks dropped, non-ASCII gone.
427#[must_use]
428pub fn ascii_fold(text: &str) -> String {
429    text.nfkd().filter(char::is_ascii).collect()
430}
431
432/// Words a folder name skips.
433const 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
439/// The longest folder name.
440pub const SLUG_MAX: usize = 40;
441
442/// The folder name for an idea: the first five significant words,
443/// ASCII-folded, lowercase `[a-z0-9-]`, at most [`SLUG_MAX`] characters;
444/// `project` when nothing is left.
445#[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
475/// Words in an idea that name a stack.
476const 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/// The stack an idea names outright — a language or its tool (`Rust`,
529/// `uv`, `TypeScript`), installed — never a kind of software (`CLI`).
530#[must_use]
531pub fn named_stack(idea: &str, installed: &[(Stack, String)]) -> Option<StackPick> {
532    cued(idea, installed, true)
533}
534
535/// The stack an idea suggests among the `installed` ones, with its reason.
536/// A named language wins over a kind of software (`CLI`, `web`, `API`);
537/// a stack whose tool is not installed is never suggested; with none
538/// installed: [`Stack::None`].
539#[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
574/// The first installed stack a word of `idea` cues: by a language's own
575/// names (the first three of its cues) when `named`, else by any cue.
576fn 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        // A kind of software names no stack: the person chooses one.
634        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}