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 suggests among the `installed` ones, with its reason.
529/// A named language wins over a kind of software (`CLI`, `web`, `API`);
530/// a stack whose tool is not installed is never suggested; with none
531/// installed: [`Stack::None`].
532#[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    // A language named outright first (the first three words of each cue list).
553    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}