story-kit: merge 148_story_interactive_onboarding_guides_user_through_project_setup_after_init

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-24 15:34:31 +00:00
parent 81ac2f309a
commit 5567cdf480
7 changed files with 476 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
pub mod fs;
pub mod onboarding;
pub mod search;
pub mod shell;
pub mod story_metadata;

260
server/src/io/onboarding.rs Normal file
View File

@@ -0,0 +1,260 @@
use std::path::Path;
/// Unique marker found in the scaffold template for `00_CONTEXT.md`.
/// If the project's context file contains this phrase, it is still the
/// default scaffold content and needs to be replaced.
const TEMPLATE_MARKER_CONTEXT: &str = "Agentic AI Code Assistant";
/// Unique marker found in the scaffold template for `STACK.md`.
const TEMPLATE_MARKER_STACK: &str = "Agentic Code Assistant";
/// Marker found in the default `script/test` scaffold output.
const TEMPLATE_MARKER_SCRIPT: &str = "No tests configured";
/// Summary of what parts of a project still need onboarding.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnboardingStatus {
/// True when the project context spec needs to be populated.
pub needs_context: bool,
/// True when the tech stack spec needs to be populated.
pub needs_stack: bool,
/// True when `script/test` still contains the scaffold placeholder.
pub needs_test_script: bool,
/// True when `.story_kit/project.toml` is missing or has no
/// `[[component]]` entries.
pub needs_project_toml: bool,
}
impl OnboardingStatus {
/// Returns `true` when any onboarding step is still needed.
pub fn needs_onboarding(&self) -> bool {
self.needs_context || self.needs_stack
}
}
/// Inspect the project at `project_root` and determine which onboarding
/// steps are still required.
pub fn check_onboarding_status(project_root: &Path) -> OnboardingStatus {
let story_kit = project_root.join(".story_kit");
OnboardingStatus {
needs_context: is_template_or_missing(
&story_kit.join("specs").join("00_CONTEXT.md"),
TEMPLATE_MARKER_CONTEXT,
),
needs_stack: is_template_or_missing(
&story_kit.join("specs").join("tech").join("STACK.md"),
TEMPLATE_MARKER_STACK,
),
needs_test_script: is_template_or_missing(
&project_root.join("script").join("test"),
TEMPLATE_MARKER_SCRIPT,
),
needs_project_toml: needs_project_toml(&story_kit),
}
}
/// Returns `true` when the file is missing, empty, or contains the
/// given scaffold marker string.
fn is_template_or_missing(path: &Path, marker: &str) -> bool {
match std::fs::read_to_string(path) {
Ok(content) => content.trim().is_empty() || content.contains(marker),
Err(_) => true,
}
}
/// Returns `true` when `project.toml` is missing or has no
/// `[[component]]` entries.
fn needs_project_toml(story_kit: &Path) -> bool {
let toml_path = story_kit.join("project.toml");
match std::fs::read_to_string(toml_path) {
Ok(content) => !content.contains("[[component]]"),
Err(_) => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
let root = dir.path().to_path_buf();
let sk = root.join(".story_kit");
fs::create_dir_all(sk.join("specs").join("tech")).unwrap();
fs::create_dir_all(root.join("script")).unwrap();
root
}
// ── needs_onboarding ──────────────────────────────────────────
#[test]
fn needs_onboarding_true_when_no_files_exist() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let status = check_onboarding_status(&root);
assert!(status.needs_onboarding());
assert!(status.needs_context);
assert!(status.needs_stack);
assert!(status.needs_test_script);
assert!(status.needs_project_toml);
}
#[test]
fn needs_onboarding_true_when_specs_contain_scaffold_markers() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
// Write scaffold template content
fs::write(
root.join(".story_kit/specs/00_CONTEXT.md"),
"# Project Context\nTo build a standalone Agentic AI Code Assistant...",
)
.unwrap();
fs::write(
root.join(".story_kit/specs/tech/STACK.md"),
"# Tech Stack\nThis is an Agentic Code Assistant binary...",
)
.unwrap();
let status = check_onboarding_status(&root);
assert!(status.needs_context);
assert!(status.needs_stack);
assert!(status.needs_onboarding());
}
#[test]
fn needs_onboarding_false_when_specs_have_custom_content() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
fs::write(
root.join(".story_kit/specs/00_CONTEXT.md"),
"# My Project\n\nThis is an e-commerce platform for selling widgets.",
)
.unwrap();
fs::write(
root.join(".story_kit/specs/tech/STACK.md"),
"# Tech Stack\n\n## Backend: Python + FastAPI\n## Frontend: React + TypeScript",
)
.unwrap();
let status = check_onboarding_status(&root);
assert!(!status.needs_context);
assert!(!status.needs_stack);
assert!(!status.needs_onboarding());
}
#[test]
fn needs_onboarding_true_when_specs_are_empty() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
fs::write(root.join(".story_kit/specs/00_CONTEXT.md"), " \n").unwrap();
fs::write(root.join(".story_kit/specs/tech/STACK.md"), "").unwrap();
let status = check_onboarding_status(&root);
assert!(status.needs_context);
assert!(status.needs_stack);
}
// ── needs_test_script ─────────────────────────────────────────
#[test]
fn needs_test_script_true_when_placeholder() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
fs::write(
root.join("script/test"),
"#!/usr/bin/env bash\nset -euo pipefail\necho \"No tests configured\"\n",
)
.unwrap();
let status = check_onboarding_status(&root);
assert!(status.needs_test_script);
}
#[test]
fn needs_test_script_false_when_customised() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
fs::write(
root.join("script/test"),
"#!/usr/bin/env bash\nset -euo pipefail\ncargo test\n",
)
.unwrap();
let status = check_onboarding_status(&root);
assert!(!status.needs_test_script);
}
// ── needs_project_toml ────────────────────────────────────────
#[test]
fn needs_project_toml_true_when_missing() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
let status = check_onboarding_status(&root);
assert!(status.needs_project_toml);
}
#[test]
fn needs_project_toml_true_when_no_components() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
fs::write(
root.join(".story_kit/project.toml"),
"# empty config\n",
)
.unwrap();
let status = check_onboarding_status(&root);
assert!(status.needs_project_toml);
}
#[test]
fn needs_project_toml_false_when_has_components() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
fs::write(
root.join(".story_kit/project.toml"),
"[[component]]\nname = \"app\"\npath = \".\"\nsetup = [\"cargo check\"]\n",
)
.unwrap();
let status = check_onboarding_status(&root);
assert!(!status.needs_project_toml);
}
// ── partial onboarding ────────────────────────────────────────
#[test]
fn needs_onboarding_true_when_only_context_is_template() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
// Context is still template
fs::write(
root.join(".story_kit/specs/00_CONTEXT.md"),
"Agentic AI Code Assistant placeholder",
)
.unwrap();
// Stack is customised
fs::write(
root.join(".story_kit/specs/tech/STACK.md"),
"# My Stack\nRuby on Rails + PostgreSQL",
)
.unwrap();
let status = check_onboarding_status(&root);
assert!(status.needs_context);
assert!(!status.needs_stack);
assert!(status.needs_onboarding());
}
}