Skip to main content

oxide_browser/
forge.rs

1//! Oxide Forge — multi-provider AI guest app generation layer.
2//!
3//! Turns a user's natural-language prompt into a compiled guest `.wasm`
4//! module that can be hot-loaded into an Oxide tab. Driven entirely by
5//! the native host UI (`oxide://forge`) — no guest-side API yet.
6//!
7//! Pipeline:
8//!
9//! 1. [`ForgeState::start`] — scaffolds a fresh project under the active
10//!    Forge output directory, copying `forge/templates/base/` and spawning a
11//!    background task that streams a Claude response into the session's
12//!    `code` buffer.
13//! 2. The UI polls [`ForgeState::snapshot`] each frame to render
14//!    progress.
15//! 3. When streaming completes, the session's `src/lib.rs` is written
16//!    to disk. The UI may then call [`ForgeState::build`], which spawns
17//!    `cargo build --target wasm32-unknown-unknown --release` in the
18//!    session directory and populates either `artifact_path` or
19//!    `build_log` on completion.
20//!
21//! API keys for Anthropic, OpenAI, Google Gemini, and xAI can be configured
22//! in the `oxide://forge` settings panel or via environment variables. The system
23//! prompt is composed at boot from the `oxide-wasm-app` Agent Skill under
24//! `forge/skills/oxide-wasm-app/` (see <https://agentskills.io/>): its
25//! `SKILL.md` body plus every markdown file it bundles in `references/`.
26
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29use std::process::Stdio;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::{Arc, Mutex};
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use anyhow::{anyhow, bail, Context, Result};
35use futures_util::StreamExt;
36use serde_json::{json, Value};
37use tokio::io::AsyncReadExt;
38use tokio::runtime::Runtime;
39
40use crate::forge_config::{ForgeProvider, ForgeUserConfig};
41
42// ── Configuration ──────────────────────────────────────────────────────────
43
44/// Default model. Override with `OXIDE_FORGE_MODEL` env var.
45pub const DEFAULT_MODEL: &str = "claude-opus-4-7";
46
47/// Max output tokens per generation. Opus 4 allows up to 8192 (non-beta).
48const MAX_TOKENS: u32 = 8192;
49
50const ANTHROPIC_VERSION: &str = "2023-06-01";
51const ANTHROPIC_URL: &str = "https://api.anthropic.com/v1/messages";
52const OPENAI_URL: &str = "https://api.openai.com/v1/chat/completions";
53const XAI_URL: &str = "https://api.x.ai/v1/chat/completions";
54const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta/models";
55
56/// How many times Forge will auto-retry on `cargo build` failure before
57/// giving up and surfacing the error to the user.
58const MAX_AUTO_RETRIES: u32 = 3;
59
60// ── Public phase / snapshot types ──────────────────────────────────────────
61
62/// Role of a message in the Forge chat thread.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum ForgeMessageRole {
65    User,
66    Assistant,
67}
68
69/// One turn in the Forge chat UI (Cursor-style conversation).
70#[derive(Clone, Debug)]
71pub struct ForgeChatMessage {
72    pub role: ForgeMessageRole,
73    pub content: String,
74}
75
76/// Coarse-grained state machine for a single Forge session, surfaced to the UI.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum ForgePhase {
79    /// Session has been created but streaming has not started.
80    Idle,
81    /// Claude is currently streaming tokens into `code`.
82    Streaming,
83    /// Streaming finished cleanly; `code` is the complete generated file.
84    StreamComplete,
85    /// `cargo build` is running in the background.
86    Building,
87    /// Build succeeded; `artifact_path` points at the `.wasm`.
88    BuildOk,
89    /// Streaming or build failed; inspect `error` / `build_log`.
90    Error,
91}
92
93/// Snapshot of a session for the UI to render. Cheap to clone (a few
94/// strings).
95#[derive(Clone, Debug)]
96pub struct ForgeSnapshot {
97    pub id: u64,
98    pub slug: String,
99    pub prompt: String,
100    pub code: String,
101    pub phase: ForgePhase,
102    pub build_log: String,
103    pub artifact_path: Option<PathBuf>,
104    pub error: Option<String>,
105    /// Monotonically increases each time the session is mutated, so the UI
106    /// can skip re-rendering identical snapshots.
107    pub revision: u64,
108    /// Number of auto-fix retries already consumed (0..=MAX_AUTO_RETRIES).
109    pub retries_used: u32,
110    /// Maximum number of auto-fix retries for this session.
111    pub max_retries: u32,
112    /// Whether auto-retry on build failure is enabled for this session.
113    pub auto_fix: bool,
114    /// Absolute project directory containing Cargo.toml, src/lib.rs, metadata,
115    /// and the exported wasm artifact.
116    pub project_dir: PathBuf,
117    /// Last mutation timestamp, milliseconds since Unix epoch.
118    pub updated_at_ms: u64,
119    /// Cursor-style chat history for this creation.
120    pub messages: Vec<ForgeChatMessage>,
121    /// Active LLM provider for this session's generations.
122    pub provider: ForgeProvider,
123    /// Model id used for the last request.
124    pub model: String,
125}
126
127#[derive(Clone, Debug)]
128pub struct ForgeCreationSummary {
129    pub id: u64,
130    pub slug: String,
131    pub prompt: String,
132    pub phase: ForgePhase,
133    pub project_dir: PathBuf,
134    pub artifact_path: Option<PathBuf>,
135    pub updated_at_ms: u64,
136}
137
138// ── Internal session state ─────────────────────────────────────────────────
139
140struct Session {
141    id: u64,
142    slug: String,
143    prompt: String,
144    code: String,
145    phase: ForgePhase,
146    build_log: String,
147    artifact_path: Option<PathBuf>,
148    error: Option<String>,
149    revision: u64,
150    /// Absolute path to the per-creation Cargo project.
151    project_dir: PathBuf,
152    retries_used: u32,
153    auto_fix: bool,
154    updated_at_ms: u64,
155    messages: Vec<ForgeChatMessage>,
156    provider: ForgeProvider,
157    model: String,
158}
159
160impl Session {
161    fn snapshot(&self) -> ForgeSnapshot {
162        ForgeSnapshot {
163            id: self.id,
164            slug: self.slug.clone(),
165            prompt: self.prompt.clone(),
166            code: self.code.clone(),
167            phase: self.phase,
168            build_log: self.build_log.clone(),
169            artifact_path: self.artifact_path.clone(),
170            error: self.error.clone(),
171            revision: self.revision,
172            retries_used: self.retries_used,
173            max_retries: MAX_AUTO_RETRIES,
174            auto_fix: self.auto_fix,
175            project_dir: self.project_dir.clone(),
176            updated_at_ms: self.updated_at_ms,
177            messages: self.messages.clone(),
178            provider: self.provider,
179            model: self.model.clone(),
180        }
181    }
182
183    fn bump(&mut self) {
184        self.revision = self.revision.wrapping_add(1);
185        self.updated_at_ms = now_ms();
186    }
187
188    fn summary(&self) -> ForgeCreationSummary {
189        ForgeCreationSummary {
190            id: self.id,
191            slug: self.slug.clone(),
192            prompt: self.prompt.clone(),
193            phase: self.phase,
194            project_dir: self.project_dir.clone(),
195            artifact_path: self.artifact_path.clone(),
196            updated_at_ms: self.updated_at_ms,
197        }
198    }
199}
200
201type SharedSession = Arc<Mutex<Session>>;
202
203// ── ForgeState ─────────────────────────────────────────────────────────────
204
205pub struct ForgeState {
206    runtime: Runtime,
207    sessions: HashMap<u64, SharedSession>,
208    next_id: AtomicU64,
209    forge_dir: PathBuf,
210    template_dir: PathBuf,
211    system_prompt: String,
212    provider: ForgeProvider,
213    api_key: String,
214    model: String,
215}
216
217impl ForgeState {
218    /// Initialise Forge from saved config and environment variables.
219    pub fn new() -> Option<Self> {
220        Self::from_config(&ForgeUserConfig::load())
221    }
222
223    /// Initialise Forge from user configuration. Returns `None` when no API
224    /// key is configured for the active provider, or the runtime cannot start.
225    pub fn from_config(config: &ForgeUserConfig) -> Option<Self> {
226        let api_key = config.active_api_key()?;
227        let provider = config.active_provider;
228        let model = config.active_model();
229        Self::with_credentials(provider, api_key, model)
230    }
231
232    /// Initialise Forge with explicit credentials (used when saving keys in UI).
233    pub fn with_api_key(api_key: impl Into<String>) -> Option<Self> {
234        let mut config = ForgeUserConfig::load();
235        config.set_api_key(config.active_provider, api_key.into());
236        Self::from_config(&config)
237    }
238
239    fn with_credentials(provider: ForgeProvider, api_key: String, model: String) -> Option<Self> {
240        let api_key = api_key.trim().to_string();
241        if api_key.is_empty() {
242            return None;
243        }
244        let runtime = Runtime::new().ok()?;
245
246        let repo_root = repo_root();
247        let forge_dir = std::env::var_os("OXIDE_FORGE_DIR")
248            .map(PathBuf::from)
249            .unwrap_or_else(|| repo_root.join("target").join("forge"));
250        let template_dir = repo_root.join("forge").join("templates").join("base");
251
252        let _ = std::fs::create_dir_all(&forge_dir);
253
254        let system_prompt = build_system_prompt(&repo_root).unwrap_or_else(|_| {
255            "You are Oxide Forge. Produce a single Rust `src/lib.rs` in \
256             one fenced code block. Import only from `oxide_sdk`. Export \
257             `start_app` and `on_frame`."
258                .to_string()
259        });
260
261        let model = if model.trim().is_empty() {
262            let from_env = std::env::var("OXIDE_FORGE_MODEL").unwrap_or_default();
263            provider.normalize_model(&from_env)
264        } else {
265            provider.normalize_model(&model)
266        };
267
268        let mut state = Self {
269            runtime,
270            sessions: HashMap::new(),
271            next_id: AtomicU64::new(1),
272            forge_dir,
273            template_dir,
274            system_prompt,
275            provider,
276            api_key,
277            model,
278        };
279        state.load_existing_sessions();
280        Some(state)
281    }
282
283    /// Apply UI configuration (provider, model, active API key).
284    pub fn apply_config(&mut self, config: &ForgeUserConfig) -> bool {
285        if let Some(key) = config.active_api_key() {
286            self.provider = config.active_provider;
287            self.model = config.active_model();
288            self.api_key = key;
289            true
290        } else {
291            false
292        }
293    }
294
295    pub fn provider(&self) -> ForgeProvider {
296        self.provider
297    }
298
299    pub fn model(&self) -> &str {
300        &self.model
301    }
302
303    pub fn set_provider(&mut self, provider: ForgeProvider) {
304        self.provider = provider;
305    }
306
307    pub fn set_model(&mut self, model: impl Into<String>) {
308        let model = model.into().trim().to_string();
309        if !model.is_empty() {
310            self.model = model;
311        }
312    }
313
314    /// Replace the API key for the active provider.
315    pub fn set_api_key(&mut self, api_key: impl Into<String>) -> bool {
316        let api_key = api_key.into().trim().to_string();
317        if api_key.is_empty() {
318            return false;
319        }
320        self.api_key = api_key;
321        true
322    }
323
324    /// Create a new session and start a background Claude stream.
325    /// Returns the session id (always `> 0`).
326    pub fn start(&mut self, prompt: String) -> Result<u64> {
327        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
328        let slug = make_slug(id);
329        let project_dir = self.forge_dir.join(&slug);
330        scaffold_project(&self.template_dir, &project_dir)
331            .with_context(|| format!("scaffold {project_dir:?}"))?;
332
333        let session = Arc::new(Mutex::new(Session {
334            id,
335            slug,
336            prompt: prompt.clone(),
337            code: String::new(),
338            phase: ForgePhase::Idle,
339            build_log: String::new(),
340            artifact_path: None,
341            error: None,
342            revision: 0,
343            project_dir,
344            retries_used: 0,
345            auto_fix: true,
346            updated_at_ms: now_ms(),
347            messages: Vec::new(),
348            provider: self.provider,
349            model: self.model.clone(),
350        }));
351
352        self.sessions.insert(id, session.clone());
353        persist_session(&session);
354
355        let system = self.system_prompt.clone();
356        let api_key = self.api_key.clone();
357        let model = self.model.clone();
358        let provider = self.provider;
359
360        self.runtime.spawn(async move {
361            run_stream_then_build(session, system, provider, api_key, model, prompt).await;
362        });
363
364        Ok(id)
365    }
366
367    /// Re-prompt an existing creation. The current `src/lib.rs` and latest
368    /// prompt are included so Claude edits the app rather than starting over.
369    pub fn revise(&mut self, id: u64, prompt: String) -> Result<()> {
370        let session = self
371            .sessions
372            .get(&id)
373            .ok_or_else(|| anyhow!("unknown forge session {id}"))?
374            .clone();
375
376        let (revision_prompt, chat_user) = {
377            let mut s = session.lock().unwrap();
378            if matches!(s.phase, ForgePhase::Streaming | ForgePhase::Building) {
379                bail!("session {id} is busy (phase={:?})", s.phase);
380            }
381            s.prompt = prompt.clone();
382            s.error = None;
383            s.build_log.clear();
384            s.artifact_path = None;
385            s.retries_used = 0;
386            s.bump();
387            persist_locked_session(&s);
388            let api = format!(
389                "Revise this existing Oxide app.\n\n\
390                 User change request:\n{}\n\n\
391                 Current src/lib.rs:\n```rust\n{}\n```\n\n\
392                 Reply with the complete updated src/lib.rs in one ```rust fenced block.",
393                prompt, s.code
394            );
395            (api, prompt)
396        };
397
398        let system = self.system_prompt.clone();
399        let api_key = self.api_key.clone();
400        let model = self.model.clone();
401        let provider = self.provider;
402        self.runtime.spawn(async move {
403            run_stream_then_build_with(
404                session,
405                system,
406                provider,
407                api_key,
408                model,
409                chat_user,
410                revision_prompt,
411            )
412            .await;
413        });
414
415        Ok(())
416    }
417
418    /// Fetch a read-only snapshot of a session, if it exists.
419    pub fn snapshot(&self, id: u64) -> Option<ForgeSnapshot> {
420        let s = self.sessions.get(&id)?;
421        let s = s.lock().ok()?;
422        Some(s.snapshot())
423    }
424
425    /// List all known session ids, oldest-first.
426    pub fn list_ids(&self) -> Vec<u64> {
427        let mut ids: Vec<u64> = self.sessions.keys().copied().collect();
428        ids.sort_unstable();
429        ids
430    }
431
432    /// List all known creations, newest-first.
433    pub fn list_creations(&self) -> Vec<ForgeCreationSummary> {
434        let mut items = self
435            .sessions
436            .values()
437            .filter_map(|s| s.lock().ok().map(|s| s.summary()))
438            .collect::<Vec<_>>();
439        items.sort_by(|a, b| {
440            b.updated_at_ms
441                .cmp(&a.updated_at_ms)
442                .then_with(|| b.id.cmp(&a.id))
443        });
444        items
445    }
446
447    /// Delete a creation and its generated project directory.
448    pub fn delete_creation(&mut self, id: u64) -> Result<()> {
449        let session = self
450            .sessions
451            .get(&id)
452            .ok_or_else(|| anyhow!("unknown forge session {id}"))?
453            .clone();
454        let project_dir = {
455            let s = session.lock().unwrap();
456            if matches!(s.phase, ForgePhase::Streaming | ForgePhase::Building) {
457                bail!("session {id} is busy (phase={:?})", s.phase);
458            }
459            s.project_dir.clone()
460        };
461        self.sessions.remove(&id);
462        if project_dir.exists() {
463            std::fs::remove_dir_all(&project_dir)
464                .with_context(|| format!("delete {}", project_dir.display()))?;
465        }
466        Ok(())
467    }
468
469    /// Kick off a `cargo build` for a session whose streaming is done.
470    /// Subsequent calls while building are no-ops. Does NOT consume an
471    /// auto-retry — this is an explicit manual rebuild (e.g. after the
472    /// user edits an error message and wants another shot).
473    pub fn build(&mut self, id: u64) -> Result<()> {
474        let session = self
475            .sessions
476            .get(&id)
477            .ok_or_else(|| anyhow!("unknown forge session {id}"))?
478            .clone();
479
480        {
481            let s = session.lock().unwrap();
482            if !matches!(
483                s.phase,
484                ForgePhase::StreamComplete | ForgePhase::Error | ForgePhase::BuildOk
485            ) {
486                bail!("session {id} is not ready to build (phase={:?})", s.phase);
487            }
488        }
489
490        // A manual build does not trigger auto-retry on failure; we just
491        // run `cargo` once and surface the result.
492        self.runtime.spawn(async move {
493            run_build(session).await;
494        });
495
496        Ok(())
497    }
498
499    /// Toggle auto-fix on a session. No-op if the session doesn't exist.
500    pub fn set_auto_fix(&mut self, id: u64, enabled: bool) {
501        if let Some(s) = self.sessions.get(&id) {
502            let mut s = s.lock().unwrap();
503            s.auto_fix = enabled;
504            s.bump();
505        }
506    }
507
508    /// Read the built artifact bytes for a session, if the build succeeded.
509    pub fn artifact_bytes(&self, id: u64) -> Option<Vec<u8>> {
510        let snap = self.snapshot(id)?;
511        let path = snap.artifact_path?;
512        std::fs::read(&path).ok()
513    }
514
515    /// Convenience for the UI: the system prompt length (tokens ~ chars/4),
516    /// so we can show a rough "context used" indicator.
517    pub fn system_prompt_len(&self) -> usize {
518        self.system_prompt.len()
519    }
520
521    pub fn output_dir(&self) -> PathBuf {
522        self.forge_dir.clone()
523    }
524
525    pub fn set_output_dir(&mut self, dir: PathBuf) -> Result<()> {
526        std::fs::create_dir_all(&dir)
527            .with_context(|| format!("create forge output dir {}", dir.display()))?;
528        self.forge_dir = dir;
529        self.load_existing_sessions();
530        Ok(())
531    }
532
533    fn load_existing_sessions(&mut self) {
534        let mut max_id = 0;
535        let mut loaded = HashMap::new();
536        let Ok(entries) = std::fs::read_dir(&self.forge_dir) else {
537            self.sessions.clear();
538            self.next_id.store(1, Ordering::SeqCst);
539            return;
540        };
541        for entry in entries.flatten() {
542            let path = entry.path();
543            if !path.is_dir() {
544                continue;
545            }
546            if let Some(session) = load_session_from_dir(&path) {
547                let id = session.lock().unwrap().id;
548                max_id = max_id.max(id);
549                loaded.insert(id, session);
550            }
551        }
552        self.sessions = loaded;
553        self.next_id.store(max_id + 1, Ordering::SeqCst);
554    }
555}
556
557// ── Orchestration: stream → build → maybe auto-retry ───────────────────────
558
559/// Run one full "generate → compile" cycle. On compile failure, if the
560/// session still has auto-retry budget, loop with a correction prompt.
561async fn run_stream_then_build(
562    session: SharedSession,
563    system: String,
564    provider: ForgeProvider,
565    api_key: String,
566    model: String,
567    initial_prompt: String,
568) {
569    run_stream_then_build_with(
570        session,
571        system,
572        provider,
573        api_key,
574        model,
575        initial_prompt.clone(),
576        initial_prompt,
577    )
578    .await;
579}
580
581async fn run_stream_then_build_with(
582    session: SharedSession,
583    system: String,
584    provider: ForgeProvider,
585    api_key: String,
586    model: String,
587    chat_user: String,
588    api_prompt: String,
589) {
590    if !stream_one_attempt(
591        &session,
592        &system,
593        provider,
594        &api_key,
595        &model,
596        &chat_user,
597        &api_prompt,
598    )
599    .await
600    {
601        return;
602    }
603
604    loop {
605        run_build(session.clone()).await;
606
607        // Inspect outcome and decide whether to auto-retry.
608        let (should_retry, retry_prompt) = {
609            let s = session.lock().unwrap();
610            let can_retry = matches!(s.phase, ForgePhase::Error)
611                && s.auto_fix
612                && s.retries_used < MAX_AUTO_RETRIES;
613            if can_retry {
614                (true, build_retry_prompt(&s))
615            } else {
616                (false, String::new())
617            }
618        };
619
620        if !should_retry {
621            break;
622        }
623
624        {
625            let mut s = session.lock().unwrap();
626            s.retries_used += 1;
627            s.code.clear();
628            s.bump();
629            persist_locked_session(&s);
630        }
631
632        if !stream_one_attempt(
633            &session,
634            &system,
635            provider,
636            &api_key,
637            &model,
638            "Fixing compile errors…",
639            &retry_prompt,
640        )
641        .await
642        {
643            break;
644        }
645    }
646}
647
648/// Stream a single generation, write the resulting code to disk, and set
649/// phase to [`ForgePhase::StreamComplete`]. Returns `true` on success.
650async fn stream_one_attempt(
651    session: &SharedSession,
652    system: &str,
653    provider: ForgeProvider,
654    api_key: &str,
655    model: &str,
656    chat_user: &str,
657    api_prompt: &str,
658) -> bool {
659    {
660        let mut s = session.lock().unwrap();
661        s.phase = ForgePhase::Streaming;
662        s.code.clear();
663        s.artifact_path = None;
664        s.error = None;
665        s.provider = provider;
666        s.model = model.to_string();
667        s.messages.push(ForgeChatMessage {
668            role: ForgeMessageRole::User,
669            content: chat_user.to_string(),
670        });
671        s.messages.push(ForgeChatMessage {
672            role: ForgeMessageRole::Assistant,
673            content: String::new(),
674        });
675        s.bump();
676        persist_locked_session(&s);
677    }
678
679    if let Err(e) = drive_llm_stream(session, system, provider, api_key, model, api_prompt).await {
680        let mut s = session.lock().unwrap();
681        s.phase = ForgePhase::Error;
682        s.error = Some(e.to_string());
683        s.bump();
684        persist_locked_session(&s);
685        return false;
686    }
687
688    let code_on_disk = {
689        let s = session.lock().unwrap();
690        extract_rust_block(&s.code)
691    };
692
693    let project_dir = session.lock().unwrap().project_dir.clone();
694    if let Err(e) = write_lib_rs(&project_dir, &code_on_disk) {
695        let mut s = session.lock().unwrap();
696        s.phase = ForgePhase::Error;
697        s.error = Some(format!("failed to write lib.rs: {e}"));
698        s.bump();
699        persist_locked_session(&s);
700        return false;
701    }
702
703    let mut s = session.lock().unwrap();
704    s.code = code_on_disk.clone();
705    if let Some(last) = s.messages.last_mut() {
706        if last.role == ForgeMessageRole::Assistant {
707            last.content = code_on_disk;
708        }
709    }
710    s.phase = ForgePhase::StreamComplete;
711    s.bump();
712    persist_locked_session(&s);
713    true
714}
715
716fn append_stream_delta(session: &SharedSession, delta: &str) {
717    if delta.is_empty() {
718        return;
719    }
720    let mut s = session.lock().unwrap();
721    s.code.push_str(delta);
722    if let Some(last) = s.messages.last_mut() {
723        if last.role == ForgeMessageRole::Assistant {
724            last.content.push_str(delta);
725        }
726    }
727    s.bump();
728}
729
730/// Compose a "please fix this" prompt from the last failed attempt.
731fn build_retry_prompt(s: &Session) -> String {
732    // Truncate excessively long logs to keep the context window bounded.
733    let log = truncate_middle(&s.build_log, 6_000);
734    format!(
735        "Your previous attempt at this app did not compile. Fix it.\n\n\
736         Original request:\n{}\n\n\
737         Previous lib.rs:\n```rust\n{}\n```\n\n\
738         Compiler output:\n```\n{}\n```\n\n\
739         Reply with the complete corrected lib.rs in one ```rust fenced block.",
740        s.prompt, s.code, log
741    )
742}
743
744/// If `text` exceeds `max_bytes`, keep the first and last halves with an
745/// elision marker in between. Byte-safe (won't split a multi-byte char).
746fn truncate_middle(text: &str, max_bytes: usize) -> String {
747    if text.len() <= max_bytes {
748        return text.to_string();
749    }
750    let half = max_bytes / 2;
751    let mut head_end = half;
752    while head_end < text.len() && !text.is_char_boundary(head_end) {
753        head_end -= 1;
754    }
755    let mut tail_start = text.len().saturating_sub(half);
756    while tail_start < text.len() && !text.is_char_boundary(tail_start) {
757        tail_start += 1;
758    }
759    format!(
760        "{}\n…[truncated {} bytes]…\n{}",
761        &text[..head_end],
762        text.len() - head_end - (text.len() - tail_start),
763        &text[tail_start..]
764    )
765}
766
767async fn drive_llm_stream(
768    session: &SharedSession,
769    system: &str,
770    provider: ForgeProvider,
771    api_key: &str,
772    model: &str,
773    api_prompt: &str,
774) -> Result<()> {
775    match provider {
776        ForgeProvider::Anthropic => {
777            drive_anthropic_stream(session, system, api_key, model, api_prompt).await
778        }
779        ForgeProvider::Openai => {
780            drive_openai_stream(session, system, api_key, model, OPENAI_URL, api_prompt).await
781        }
782        ForgeProvider::Xai => {
783            drive_openai_stream(session, system, api_key, model, XAI_URL, api_prompt).await
784        }
785        ForgeProvider::Gemini => {
786            drive_gemini_stream(session, system, api_key, model, api_prompt).await
787        }
788    }
789}
790
791fn http_client() -> Result<reqwest::Client> {
792    reqwest::Client::builder()
793        .connect_timeout(std::time::Duration::from_secs(20))
794        .read_timeout(std::time::Duration::from_secs(120))
795        .build()
796        .context("build http client")
797}
798
799fn chat_history_for_api(session: &SharedSession, api_prompt: &str) -> Vec<(String, String)> {
800    let s = session.lock().unwrap();
801    let mut history: Vec<(String, String)> = s
802        .messages
803        .iter()
804        .filter(|m| !m.content.is_empty())
805        .map(|m| {
806            let role = match m.role {
807                ForgeMessageRole::User => "user",
808                ForgeMessageRole::Assistant => "assistant",
809            };
810            (role.to_string(), m.content.clone())
811        })
812        .collect();
813    // Replace the last user turn with the full API prompt (may include code context).
814    if let Some((role, content)) = history.last_mut() {
815        if role == "user" {
816            *content = api_prompt.to_string();
817        }
818    }
819    history
820}
821
822async fn consume_sse_stream(
823    session: &SharedSession,
824    resp: reqwest::Response,
825    parse_event: impl Fn(&[u8]) -> Option<String>,
826) -> Result<()> {
827    let status = resp.status();
828    if !status.is_success() {
829        let err_body = resp.text().await.unwrap_or_default();
830        bail!("API {}: {}", status, err_body);
831    }
832
833    let mut stream = resp.bytes_stream();
834    let mut buf = Vec::<u8>::new();
835    while let Some(next) = stream.next().await {
836        let chunk = next.context("stream read")?;
837        buf.extend_from_slice(&chunk);
838
839        while let Some(pos) = find_event_boundary(&buf) {
840            let event = buf.drain(..pos).collect::<Vec<u8>>();
841            let skip = if buf.starts_with(b"\r\n\r\n") { 4 } else { 2 };
842            buf.drain(..skip.min(buf.len()));
843
844            if let Some(delta) = parse_event(&event) {
845                append_stream_delta(session, &delta);
846            }
847        }
848    }
849    Ok(())
850}
851
852async fn drive_anthropic_stream(
853    session: &SharedSession,
854    system: &str,
855    api_key: &str,
856    model: &str,
857    api_prompt: &str,
858) -> Result<()> {
859    let client = http_client()?;
860    let history = chat_history_for_api(session, api_prompt);
861    let messages: Vec<Value> = history
862        .into_iter()
863        .map(|(role, content)| json!({ "role": role, "content": content }))
864        .collect();
865
866    let body = json!({
867        "model": model,
868        "max_tokens": MAX_TOKENS,
869        "stream": true,
870        "system": system,
871        "messages": messages,
872    });
873    let body_bytes = serde_json::to_vec(&body).context("serialise request body")?;
874
875    let resp = client
876        .post(ANTHROPIC_URL)
877        .header("x-api-key", api_key)
878        .header("anthropic-version", ANTHROPIC_VERSION)
879        .header("content-type", "application/json")
880        .body(body_bytes)
881        .send()
882        .await
883        .context("POST anthropic /v1/messages")?;
884
885    consume_sse_stream(session, resp, parse_anthropic_sse_event).await
886}
887
888async fn drive_openai_stream(
889    session: &SharedSession,
890    system: &str,
891    api_key: &str,
892    model: &str,
893    url: &str,
894    api_prompt: &str,
895) -> Result<()> {
896    let client = http_client()?;
897    let history = chat_history_for_api(session, api_prompt);
898    let mut messages = vec![json!({ "role": "system", "content": system })];
899    for (role, content) in history {
900        messages.push(json!({ "role": role, "content": content }));
901    }
902
903    let body = json!({
904        "model": model,
905        "max_tokens": MAX_TOKENS,
906        "stream": true,
907        "messages": messages,
908    });
909    let body_bytes = serde_json::to_vec(&body).context("serialise request body")?;
910
911    let resp = client
912        .post(url)
913        .header("Authorization", format!("Bearer {api_key}"))
914        .header("content-type", "application/json")
915        .body(body_bytes)
916        .send()
917        .await
918        .context("POST chat/completions")?;
919
920    consume_sse_stream(session, resp, parse_openai_sse_event).await
921}
922
923async fn drive_gemini_stream(
924    session: &SharedSession,
925    system: &str,
926    api_key: &str,
927    model: &str,
928    api_prompt: &str,
929) -> Result<()> {
930    let client = http_client()?;
931    let history = chat_history_for_api(session, api_prompt);
932    let mut contents = Vec::new();
933    for (role, content) in history {
934        let gemini_role = if role == "assistant" { "model" } else { "user" };
935        contents.push(json!({
936            "role": gemini_role,
937            "parts": [{ "text": content }],
938        }));
939    }
940
941    let body = json!({
942        "systemInstruction": { "parts": [{ "text": system }] },
943        "contents": contents,
944        "generationConfig": { "maxOutputTokens": MAX_TOKENS },
945    });
946    let body_bytes = serde_json::to_vec(&body).context("serialise request body")?;
947    let url = format!("{GEMINI_BASE}/{model}:streamGenerateContent?alt=sse");
948
949    let resp = client
950        .post(&url)
951        .header("x-goog-api-key", api_key)
952        .header("content-type", "application/json")
953        .body(body_bytes)
954        .send()
955        .await
956        .context("POST gemini streamGenerateContent")?;
957
958    consume_sse_stream(session, resp, parse_gemini_sse_event).await
959}
960
961/// Find the end of the first complete SSE event (`\n\n` or `\r\n\r\n`)
962/// and return the index of the first byte of the separator.
963fn find_event_boundary(buf: &[u8]) -> Option<usize> {
964    for i in 0..buf.len().saturating_sub(1) {
965        if buf[i] == b'\n' && buf[i + 1] == b'\n' {
966            return Some(i);
967        }
968        if i + 3 < buf.len() && &buf[i..i + 4] == b"\r\n\r\n" {
969            return Some(i);
970        }
971    }
972    None
973}
974
975fn parse_anthropic_sse_event(event: &[u8]) -> Option<String> {
976    parse_sse_event_with(event, |kind, v| match kind {
977        "content_block_delta" => {
978            let t = v.get("delta")?.get("text")?.as_str()?;
979            Some(t.to_string())
980        }
981        _ => None,
982    })
983}
984
985fn parse_openai_sse_event(event: &[u8]) -> Option<String> {
986    parse_sse_event_with(event, |kind, v| {
987        let _ = kind;
988        v.get("choices")?
989            .as_array()?
990            .first()?
991            .get("delta")?
992            .get("content")?
993            .as_str()
994            .map(|s| s.to_string())
995    })
996}
997
998fn parse_gemini_sse_event(event: &[u8]) -> Option<String> {
999    parse_sse_event_with(event, |kind, v| {
1000        let _ = kind;
1001        v.get("candidates")?
1002            .as_array()?
1003            .first()?
1004            .get("content")?
1005            .get("parts")?
1006            .as_array()?
1007            .first()?
1008            .get("text")?
1009            .as_str()
1010            .map(|s| s.to_string())
1011    })
1012}
1013
1014/// Parse a single SSE event (already stripped of trailing blank line).
1015/// Returns the text delta contribution, if any.
1016fn parse_sse_event_with(
1017    event: &[u8],
1018    extract: impl Fn(&str, &Value) -> Option<String>,
1019) -> Option<String> {
1020    // Only care about `data: {...}` lines. Concatenate multi-line data values.
1021    let text = std::str::from_utf8(event).ok()?;
1022    let mut data = String::new();
1023    for line in text.lines() {
1024        if let Some(rest) = line.strip_prefix("data:") {
1025            data.push_str(rest.trim_start());
1026            data.push('\n');
1027        }
1028    }
1029    if data.is_empty() {
1030        return None;
1031    }
1032    let data = data.trim();
1033    if data == "[DONE]" {
1034        return None;
1035    }
1036    let v: Value = serde_json::from_str(data).ok()?;
1037    let kind = v
1038        .get("type")
1039        .and_then(Value::as_str)
1040        .or_else(|| v.get("object").and_then(Value::as_str))
1041        .unwrap_or("");
1042    extract(kind, &v)
1043}
1044
1045#[cfg(test)]
1046fn parse_sse_event(event: &[u8]) -> Option<String> {
1047    parse_anthropic_sse_event(event)
1048}
1049
1050/// Extract the Rust source from a response that may (but need not) include
1051/// a ```rust … ``` fence. Falls back to returning the raw response.
1052fn extract_rust_block(reply: &str) -> String {
1053    if let Some(start) = reply.find("```rust") {
1054        let after = &reply[start + "```rust".len()..];
1055        // Skip optional newline.
1056        let after = after.strip_prefix('\n').unwrap_or(after);
1057        if let Some(end) = after.find("```") {
1058            return after[..end].trim_end().to_string();
1059        }
1060    }
1061    if let Some(start) = reply.find("```") {
1062        let after = &reply[start + 3..];
1063        let after = after.strip_prefix('\n').unwrap_or(after);
1064        if let Some(end) = after.find("```") {
1065            return after[..end].trim_end().to_string();
1066        }
1067    }
1068    reply.trim().to_string()
1069}
1070
1071// ── Project scaffolding ────────────────────────────────────────────────────
1072
1073fn scaffold_project(template: &Path, project_dir: &Path) -> Result<()> {
1074    std::fs::create_dir_all(project_dir.join("src"))?;
1075
1076    let cargo_toml = template.join("Cargo.toml");
1077    let lib_rs = template.join("src").join("lib.rs");
1078
1079    if cargo_toml.is_file() {
1080        let mut cargo = std::fs::read_to_string(&cargo_toml)?;
1081        let sdk_path = toml_string(&repo_root().join("oxide-sdk").to_string_lossy());
1082        cargo = cargo.replace(
1083            "oxide-sdk = { path = \"../../../oxide-sdk\" }",
1084            &format!("oxide-sdk = {{ path = \"{sdk_path}\" }}"),
1085        );
1086        std::fs::write(project_dir.join("Cargo.toml"), cargo)?;
1087    } else {
1088        bail!("template Cargo.toml missing at {cargo_toml:?}");
1089    }
1090    if lib_rs.is_file() {
1091        std::fs::copy(&lib_rs, project_dir.join("src").join("lib.rs"))?;
1092    } else {
1093        bail!("template src/lib.rs missing at {lib_rs:?}");
1094    }
1095
1096    Ok(())
1097}
1098
1099fn write_lib_rs(project_dir: &Path, code: &str) -> Result<()> {
1100    let path = project_dir.join("src").join("lib.rs");
1101    std::fs::write(&path, code).with_context(|| format!("write {path:?}"))?;
1102    Ok(())
1103}
1104
1105fn toml_string(s: &str) -> String {
1106    s.replace('\\', "\\\\").replace('"', "\\\"")
1107}
1108
1109// ── Cargo build ────────────────────────────────────────────────────────────
1110
1111async fn run_build(session: SharedSession) {
1112    let project_dir = {
1113        let mut s = session.lock().unwrap();
1114        s.phase = ForgePhase::Building;
1115        s.build_log.clear();
1116        s.error = None;
1117        s.bump();
1118        persist_locked_session(&s);
1119        s.project_dir.clone()
1120    };
1121
1122    let mut cmd = tokio::process::Command::new("cargo");
1123    cmd.arg("build")
1124        .arg("--target")
1125        .arg("wasm32-unknown-unknown")
1126        .arg("--release")
1127        .arg("--quiet")
1128        .arg("--color")
1129        .arg("never")
1130        .env("CARGO_TERM_COLOR", "never")
1131        .current_dir(&project_dir)
1132        .stdin(Stdio::null())
1133        .stdout(Stdio::piped())
1134        .stderr(Stdio::piped());
1135
1136    let mut child = match cmd.spawn() {
1137        Ok(c) => c,
1138        Err(e) => {
1139            let mut s = session.lock().unwrap();
1140            s.phase = ForgePhase::Error;
1141            s.error = Some(format!("spawn cargo: {e}"));
1142            s.bump();
1143            persist_locked_session(&s);
1144            return;
1145        }
1146    };
1147
1148    // Drain stderr (cargo diagnostics) into the session log.
1149    let mut stderr_buf = String::new();
1150    if let Some(mut stderr) = child.stderr.take() {
1151        let _ = stderr.read_to_string(&mut stderr_buf).await;
1152    }
1153    // Ignore stdout — quiet mode produces nothing on success.
1154    if let Some(mut stdout) = child.stdout.take() {
1155        let mut _discard = String::new();
1156        let _ = stdout.read_to_string(&mut _discard).await;
1157    }
1158
1159    let status = child.wait().await;
1160
1161    let mut s = session.lock().unwrap();
1162    s.build_log = stderr_buf;
1163
1164    match status {
1165        Ok(st) if st.success() => {
1166            let artifact = project_dir
1167                .join("target")
1168                .join("wasm32-unknown-unknown")
1169                .join("release")
1170                .join("forge_app.wasm");
1171            if artifact.is_file() {
1172                let exported = s.project_dir.join(format!("{}.wasm", s.slug));
1173                match std::fs::copy(&artifact, &exported) {
1174                    Ok(_) => {
1175                        s.artifact_path = Some(exported);
1176                        s.phase = ForgePhase::BuildOk;
1177                    }
1178                    Err(e) => {
1179                        s.artifact_path = Some(artifact);
1180                        s.phase = ForgePhase::Error;
1181                        s.error = Some(format!("copy wasm artifact failed: {e}"));
1182                    }
1183                }
1184            } else {
1185                s.phase = ForgePhase::Error;
1186                s.error = Some(format!("cargo returned success but {artifact:?} missing"));
1187            }
1188        }
1189        Ok(st) => {
1190            s.phase = ForgePhase::Error;
1191            s.error = Some(format!("cargo exited with {st}"));
1192        }
1193        Err(e) => {
1194            s.phase = ForgePhase::Error;
1195            s.error = Some(format!("cargo wait failed: {e}"));
1196        }
1197    }
1198    s.bump();
1199    persist_locked_session(&s);
1200}
1201
1202// ── Helpers ────────────────────────────────────────────────────────────────
1203
1204fn repo_root() -> PathBuf {
1205    // `oxide-browser` sits at repo_root/oxide-browser. One up from its
1206    // manifest dir is the repo root during `cargo run`.
1207    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1208        .parent()
1209        .map(PathBuf::from)
1210        .unwrap_or_else(|| PathBuf::from("."))
1211}
1212
1213fn make_slug(id: u64) -> String {
1214    let secs = SystemTime::now()
1215        .duration_since(UNIX_EPOCH)
1216        .map(|d| d.as_secs())
1217        .unwrap_or(0);
1218    format!("s{secs:010}-{id:04}")
1219}
1220
1221fn now_ms() -> u64 {
1222    SystemTime::now()
1223        .duration_since(UNIX_EPOCH)
1224        .map(|d| d.as_millis() as u64)
1225        .unwrap_or(0)
1226}
1227
1228fn metadata_path(project_dir: &Path) -> PathBuf {
1229    project_dir.join("forge.json")
1230}
1231
1232fn phase_to_str(phase: ForgePhase) -> &'static str {
1233    match phase {
1234        ForgePhase::Idle => "idle",
1235        ForgePhase::Streaming => "streaming",
1236        ForgePhase::StreamComplete => "stream_complete",
1237        ForgePhase::Building => "building",
1238        ForgePhase::BuildOk => "build_ok",
1239        ForgePhase::Error => "error",
1240    }
1241}
1242
1243fn str_to_phase(s: &str) -> ForgePhase {
1244    match s {
1245        "build_ok" => ForgePhase::BuildOk,
1246        "error" => ForgePhase::Error,
1247        "building" | "streaming" | "stream_complete" => ForgePhase::StreamComplete,
1248        _ => ForgePhase::StreamComplete,
1249    }
1250}
1251
1252fn persist_session(session: &SharedSession) {
1253    if let Ok(s) = session.lock() {
1254        persist_locked_session(&s);
1255    }
1256}
1257
1258fn persist_locked_session(s: &Session) {
1259    let artifact = s
1260        .artifact_path
1261        .as_ref()
1262        .map(|p| p.to_string_lossy().to_string());
1263    let messages: Vec<Value> = s
1264        .messages
1265        .iter()
1266        .map(|m| {
1267            let role = match m.role {
1268                ForgeMessageRole::User => "user",
1269                ForgeMessageRole::Assistant => "assistant",
1270            };
1271            json!({ "role": role, "content": m.content })
1272        })
1273        .collect();
1274    let meta = json!({
1275        "id": s.id,
1276        "slug": s.slug,
1277        "prompt": s.prompt,
1278        "phase": phase_to_str(s.phase),
1279        "artifact_path": artifact,
1280        "updated_at_ms": s.updated_at_ms,
1281        "retries_used": s.retries_used,
1282        "auto_fix": s.auto_fix,
1283        "provider": s.provider.id(),
1284        "model": s.model,
1285        "messages": messages,
1286    });
1287    let _ = std::fs::write(
1288        metadata_path(&s.project_dir),
1289        serde_json::to_string_pretty(&meta).unwrap_or_else(|_| "{}".to_string()),
1290    );
1291}
1292
1293fn load_session_from_dir(project_dir: &Path) -> Option<SharedSession> {
1294    let meta = std::fs::read_to_string(metadata_path(project_dir)).ok()?;
1295    let v: Value = serde_json::from_str(&meta).ok()?;
1296    let id = v.get("id")?.as_u64()?;
1297    let slug = v.get("slug")?.as_str()?.to_string();
1298    let prompt = v
1299        .get("prompt")
1300        .and_then(Value::as_str)
1301        .unwrap_or("")
1302        .to_string();
1303    let code = std::fs::read_to_string(project_dir.join("src").join("lib.rs")).unwrap_or_default();
1304    let artifact_path = v
1305        .get("artifact_path")
1306        .and_then(Value::as_str)
1307        .map(PathBuf::from)
1308        .filter(|p| p.is_file())
1309        .or_else(|| {
1310            let p = project_dir.join(format!("{slug}.wasm"));
1311            p.is_file().then_some(p)
1312        });
1313    let phase = if artifact_path.is_some() {
1314        ForgePhase::BuildOk
1315    } else {
1316        str_to_phase(v.get("phase").and_then(Value::as_str).unwrap_or(""))
1317    };
1318    let provider = v
1319        .get("provider")
1320        .and_then(Value::as_str)
1321        .and_then(ForgeProvider::from_id)
1322        .unwrap_or(ForgeProvider::Anthropic);
1323    let model =
1324        provider.normalize_model(v.get("model").and_then(Value::as_str).unwrap_or_default());
1325    let messages = v
1326        .get("messages")
1327        .and_then(Value::as_array)
1328        .map(|arr| {
1329            arr.iter()
1330                .filter_map(|item| {
1331                    let role = item.get("role")?.as_str()?;
1332                    let content = item.get("content")?.as_str()?.to_string();
1333                    let role = match role {
1334                        "assistant" => ForgeMessageRole::Assistant,
1335                        _ => ForgeMessageRole::User,
1336                    };
1337                    Some(ForgeChatMessage { role, content })
1338                })
1339                .collect::<Vec<_>>()
1340        })
1341        .unwrap_or_default();
1342    Some(Arc::new(Mutex::new(Session {
1343        id,
1344        slug,
1345        prompt,
1346        code,
1347        phase,
1348        build_log: String::new(),
1349        artifact_path,
1350        error: None,
1351        revision: 0,
1352        project_dir: project_dir.to_path_buf(),
1353        retries_used: v.get("retries_used").and_then(Value::as_u64).unwrap_or(0) as u32,
1354        auto_fix: v.get("auto_fix").and_then(Value::as_bool).unwrap_or(true),
1355        updated_at_ms: v
1356            .get("updated_at_ms")
1357            .and_then(Value::as_u64)
1358            .unwrap_or_else(now_ms),
1359        messages,
1360        provider,
1361        model,
1362    })))
1363}
1364
1365/// Name of the Agent Skill powering Forge generations. The folder layout
1366/// follows the agentskills.io spec: `<skill>/SKILL.md` plus bundled
1367/// resources under `<skill>/references/`.
1368const FORGE_SKILL_NAME: &str = "oxide-wasm-app";
1369
1370/// Compose the Claude system prompt from the Forge Agent Skill.
1371///
1372/// This loads `forge/skills/<skill>/SKILL.md`, strips its YAML
1373/// frontmatter (per the agentskills.io spec), and appends every markdown
1374/// file in `forge/skills/<skill>/references/` so that the single
1375/// Anthropic Messages call has all capability, pattern, and recipe
1376/// context in-scope. References are sorted for determinism.
1377fn build_system_prompt(repo_root: &Path) -> Result<String> {
1378    let skill_dir = repo_root
1379        .join("forge")
1380        .join("skills")
1381        .join(FORGE_SKILL_NAME);
1382    let skill_md_path = skill_dir.join("SKILL.md");
1383    let skill_md = std::fs::read_to_string(&skill_md_path)
1384        .with_context(|| format!("read skill at {}", skill_md_path.display()))?;
1385    let (frontmatter, body) = split_skill_frontmatter(&skill_md);
1386    if frontmatter.is_none() {
1387        bail!(
1388            "skill {} is missing required YAML frontmatter (see https://agentskills.io/specification)",
1389            skill_md_path.display()
1390        );
1391    }
1392
1393    let mut out = String::with_capacity(body.len() + 8 * 1024);
1394    out.push_str(body.trim_start());
1395
1396    let references_dir = skill_dir.join("references");
1397    let mut reference_files: Vec<PathBuf> = match std::fs::read_dir(&references_dir) {
1398        Ok(entries) => entries
1399            .flatten()
1400            .map(|e| e.path())
1401            .filter(|p| {
1402                p.is_file()
1403                    && p.extension()
1404                        .and_then(|e| e.to_str())
1405                        .map(|e| e.eq_ignore_ascii_case("md"))
1406                        .unwrap_or(false)
1407            })
1408            .collect(),
1409        Err(_) => Vec::new(),
1410    };
1411    reference_files.sort();
1412
1413    for path in reference_files {
1414        let body = std::fs::read_to_string(&path)
1415            .with_context(|| format!("read reference {}", path.display()))?;
1416        let title = path
1417            .file_stem()
1418            .and_then(|s| s.to_str())
1419            .unwrap_or("Reference");
1420        out.push_str("\n\n---\n\n# Reference: ");
1421        out.push_str(title);
1422        out.push_str("\n\n");
1423        out.push_str(body.trim_end());
1424        out.push('\n');
1425    }
1426
1427    Ok(out)
1428}
1429
1430/// Split a SKILL.md document into its YAML frontmatter and markdown body.
1431/// The frontmatter is the optional leading `---\n…\n---\n` block defined
1432/// by the agentskills.io specification. Returns `(None, whole_text)` when
1433/// no frontmatter is present.
1434fn split_skill_frontmatter(doc: &str) -> (Option<&str>, &str) {
1435    let rest = match doc.strip_prefix("---\n") {
1436        Some(r) => r,
1437        None => match doc.strip_prefix("---\r\n") {
1438            Some(r) => r,
1439            None => return (None, doc),
1440        },
1441    };
1442    // Find the closing `---` on its own line.
1443    let mut search_from = 0usize;
1444    while let Some(rel) = rest[search_from..].find("\n---") {
1445        let end = search_from + rel;
1446        let after_marker = end + "\n---".len();
1447        let tail = &rest[after_marker..];
1448        let is_line_terminated =
1449            tail.is_empty() || tail.starts_with('\n') || tail.starts_with("\r\n");
1450        if is_line_terminated {
1451            let fm = &rest[..end];
1452            // Skip the line-terminator after `---`.
1453            let body_start = if tail.starts_with("\r\n") {
1454                after_marker + 2
1455            } else if tail.starts_with('\n') {
1456                after_marker + 1
1457            } else {
1458                after_marker
1459            };
1460            return (Some(fm), &rest[body_start..]);
1461        }
1462        search_from = end + 1;
1463    }
1464    (None, doc)
1465}
1466
1467// ── Tests ──────────────────────────────────────────────────────────────────
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472
1473    #[test]
1474    fn extracts_rust_fence() {
1475        let reply = "Here is your app:\n```rust\nuse oxide_sdk::*;\n```\n";
1476        assert_eq!(extract_rust_block(reply), "use oxide_sdk::*;");
1477    }
1478
1479    #[test]
1480    fn extracts_plain_fence() {
1481        let reply = "```\nuse oxide_sdk::*;\n```\nfootnote";
1482        assert_eq!(extract_rust_block(reply), "use oxide_sdk::*;");
1483    }
1484
1485    #[test]
1486    fn passthrough_when_no_fence() {
1487        let reply = "use oxide_sdk::*;\nfn main(){}";
1488        assert_eq!(extract_rust_block(reply), reply.trim());
1489    }
1490
1491    #[test]
1492    fn slug_has_expected_shape() {
1493        let s = make_slug(42);
1494        assert!(s.starts_with('s'), "slug was: {s}");
1495        assert!(s.ends_with("-0042"), "slug was: {s}");
1496        // `s` + 10 digits of secs + `-` + 4 digits of id = 16 (until epoch >= 10^10).
1497        assert_eq!(s.len(), 16, "slug was: {s}");
1498    }
1499
1500    #[test]
1501    fn parses_content_block_delta() {
1502        let event = b"event: content_block_delta\n\
1503                      data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}";
1504        assert_eq!(parse_sse_event(event).as_deref(), Some("hi"));
1505    }
1506
1507    #[test]
1508    fn ignores_ping_events() {
1509        let event = b"event: ping\ndata: {\"type\":\"ping\"}";
1510        assert_eq!(parse_sse_event(event), None);
1511    }
1512
1513    #[test]
1514    fn finds_sse_boundary() {
1515        let buf = b"data: x\n\ndata: y\n\n";
1516        assert_eq!(find_event_boundary(buf), Some(7));
1517    }
1518
1519    #[test]
1520    fn scaffold_copies_template() {
1521        let tmp = tempfile::tempdir().unwrap();
1522        let template = repo_root().join("forge").join("templates").join("base");
1523        assert!(template.is_dir(), "template must exist");
1524
1525        let project = tmp.path().join("sandbox-project");
1526        scaffold_project(&template, &project).expect("scaffold");
1527
1528        assert!(project.join("Cargo.toml").is_file());
1529        assert!(project.join("src").join("lib.rs").is_file());
1530        let cargo = std::fs::read_to_string(project.join("Cargo.toml")).unwrap();
1531        assert!(cargo.contains(&repo_root().join("oxide-sdk").to_string_lossy().to_string()));
1532
1533        // Overwrite with a guaranteed-valid tiny lib.rs and ensure it sticks.
1534        let code = "pub fn hi() -> i32 { 42 }";
1535        write_lib_rs(&project, code).expect("write lib.rs");
1536        let written = std::fs::read_to_string(project.join("src").join("lib.rs")).unwrap();
1537        assert_eq!(written, code);
1538    }
1539
1540    #[test]
1541    fn repo_root_contains_forge_skill() {
1542        // Sanity check that `repo_root()` points at the workspace root and the
1543        // `oxide-wasm-app` Agent Skill is wired up.
1544        let root = repo_root();
1545        let skill = root
1546            .join("forge")
1547            .join("skills")
1548            .join(FORGE_SKILL_NAME)
1549            .join("SKILL.md");
1550        assert!(skill.is_file(), "missing skill at {}", skill.display());
1551        assert!(root.join("oxide-sdk").join("Cargo.toml").is_file());
1552    }
1553
1554    #[test]
1555    fn build_system_prompt_non_empty_and_references_contract() {
1556        let prompt = build_system_prompt(&repo_root()).expect("build prompt");
1557        // Must be at least a few KB — the full reference is substantial.
1558        assert!(prompt.len() > 5_000, "prompt too small: {}", prompt.len());
1559        // Must embed the core rules section.
1560        assert!(prompt.contains("Oxide Forge — Guest WASM App Skill"));
1561        assert!(prompt.contains("start_app"));
1562        assert!(prompt.contains("on_frame"));
1563        // YAML frontmatter must be stripped.
1564        assert!(!prompt.starts_with("---"));
1565        assert!(!prompt.contains("name: oxide-wasm-app"));
1566        // Bundled references must be appended.
1567        assert!(prompt.contains("Reference: CAPABILITIES"));
1568        assert!(prompt.contains("Reference: PATTERNS"));
1569        assert!(prompt.contains("Reference: RECIPES"));
1570    }
1571
1572    #[test]
1573    fn splits_skill_frontmatter() {
1574        let doc = "---\nname: demo\ndescription: test\n---\n# Body\ntext\n";
1575        let (fm, body) = split_skill_frontmatter(doc);
1576        assert_eq!(fm, Some("name: demo\ndescription: test"));
1577        assert_eq!(body, "# Body\ntext\n");
1578    }
1579
1580    #[test]
1581    fn missing_frontmatter_passes_through() {
1582        let doc = "# No frontmatter\n";
1583        let (fm, body) = split_skill_frontmatter(doc);
1584        assert!(fm.is_none());
1585        assert_eq!(body, doc);
1586    }
1587}