Skip to main content

fresh/
init_script.rs

1//! User init.ts support.
2//!
3//! At startup Fresh reads `~/.config/fresh/init.ts` (if present) and feeds it
4//! through the existing plugin pipeline as a plugin named `init.ts`. This is
5//! the same code path as "Load Plugin from Buffer", so reload, unload, and
6//! per-plugin registration tagging are free.
7//!
8//! Recovery: a lightweight crash fuse at
9//! `~/.config/fresh/logs/init.crashes` counts consecutive init.ts failures
10//! within a rolling window. After N failures the next launch auto-skips
11//! init.ts until the user fixes or removes it. A successful evaluation
12//! clears the counter.
13
14use std::path::{Path, PathBuf};
15
16/// How many consecutive failed attempts trigger auto-skip.
17const CRASH_FUSE_THRESHOLD: u32 = 3;
18/// Rolling window (seconds) beyond which stale failures are ignored.
19const CRASH_FUSE_WINDOW_SECS: u64 = 300;
20/// Plugin name Fresh uses when loading init.ts — stable so hot-reload works.
21pub const INIT_PLUGIN_NAME: &str = "init.ts";
22
23/// Starter content written by `init: Edit init.ts` when the file doesn't
24/// exist yet. Every example is commented out — an empty init() body is
25/// valid and users un-comment what they want.
26///
27/// The comments establish what init.ts is *not* for (static preferences,
28/// keybindings, themes, reusable features) so users don't reach for this
29/// file when another surface is the right tool.
30pub const STARTER_TEMPLATE: &str = r#"/// <reference path="./types/fresh.d.ts" />
31/// <reference path="./types/plugins.d.ts" />
32const editor = getEditor();
33
34// Fresh init.ts — decisions that depend on the environment at startup.
35//
36// init.ts is NOT for:
37//   - Static preferences (tab size, line numbers, ...)  -> Settings UI
38//   - Key bindings                                      -> Keybindings editor
39//   - Themes you always want                            -> Theme selector
40//   - Reusable features                                 -> A plugin package
41//
42// init.ts IS for things that:
43//   - Depend on where/how Fresh is starting (host, SSH, $TERM, project, ...)
44//   - Would differ across machines or launches
45//   - Can't live in a shared config.json without lying to teammates
46//
47// API reference: ~/.config/fresh/types/fresh.d.ts (same as plugins)
48// Commands:  Ctrl+P -> "init: Reload", "init: Check"
49// CLI:       fresh --cmd init check | fresh --safe | fresh --no-init
50
51// Example: fade the editor in from black to the target theme. Uses
52// `overrideThemeColors` (in-memory, no disk I/O) for each frame, then
53// calls `applyTheme` at the end to drop the overrides and land cleanly
54// on the saved theme. `editor.delay(ms)` returns a Promise, so an async
55// for-loop is all the timing machinery we need — no setInterval.
56// (async () => {
57//     const target = "one-dark";
58//     const data = editor.getThemeData(target) as
59//         | { editor?: Record<string, [number, number, number]> }
60//         | null;
61//     const bg = data?.editor?.bg ?? [30, 30, 30];
62//     const fg = data?.editor?.fg ?? [220, 220, 220];
63//     const frames = 18;
64//     const stepMs = 16;
65//     const lerp = (a: number, b: number, t: number) =>
66//         Math.round(a + (b - a) * t);
67//     for (let i = 1; i <= frames; i++) {
68//         const t = i / frames;
69//         editor.overrideThemeColors({
70//             "editor.bg": [lerp(0, bg[0], t), lerp(0, bg[1], t), lerp(0, bg[2], t)],
71//             "editor.fg": [lerp(0, fg[0], t), lerp(0, fg[1], t), lerp(0, fg[2], t)],
72//         });
73//         await editor.delay(stepMs);
74//     }
75//     editor.applyTheme(target); // drop overrides, settle on the real theme
76// })();
77
78// Example: calmer UI over SSH. setSetting writes to the runtime layer —
79// nothing is persisted to disk, and removing this file is a complete undo.
80// if (editor.getEnv("SSH_TTY")) {
81//     editor.setSetting("editor.diagnostics_inline_text", false);
82//     editor.setSetting("terminal.mouse", false);
83// }
84
85// Example: host-specific rust-analyzer path.
86// if (editor.getEnv("HOSTNAME") === "my-mac") {
87//     editor.registerLspServer("rust", {
88//         command: "/opt/homebrew/bin/rust-analyzer",
89//         args: [],
90//         autoStart: true,
91//         initializationOptions: null,
92//         processLimits: null,
93//     });
94// }
95
96// Example: env-driven profile (fresh invoked as FRESH_PROFILE=writing fresh).
97// if (editor.getEnv("FRESH_PROFILE") === "writing") {
98//     editor.setSetting("editor.line_wrap", true);
99//     editor.setSetting("editor.wrap_column", 80);
100// }
101
102// Example: configure a plugin once it loads. `plugins_loaded` fires after
103// every registry plugin and init.ts top-level code has run.
104// editor.on("plugins_loaded", () => {
105//     const api = editor.getPluginApi("my-plugin");
106//     if (api) api.configure({ option: "value" });
107// });
108
109// Example: enable the opt-in Dashboard widgets (weather, GitHub).
110// Both hit the network on every refresh, so the plugin ships with
111// only `git` and `disk` registered by default. The handlers live
112// on the exported plugin API as `builtinHandlers` — pass them to
113// `registerSection` with whatever name you like.
114//
115// editor.on("plugins_loaded", () => {
116//     const dash = editor.getPluginApi("dashboard");
117//     if (!dash) return;
118//     dash.registerSection("weather", dash.builtinHandlers.weather);
119//     dash.registerSection("github", dash.builtinHandlers.github);
120// });
121
122// Example: disable the Dashboard's auto-open behaviour on this
123// machine (it will still be available via the "Show Dashboard"
124// command). The same toggle can also be set persistently in
125// config.json at `plugins.dashboard.auto-open`.
126//
127// editor.on("plugins_loaded", () => {
128//     const dash = editor.getPluginApi("dashboard");
129//     if (dash) dash.setAutoOpen(false);
130// });
131
132// Example: add a custom section to the Dashboard plugin.
133//
134// `editor.getPluginApi("dashboard")` is typed automatically via
135// `types/plugins.d.ts` — no `as` cast needed. Hover over `dash` or
136// `ctx` in your editor to see the full API.
137//
138// editor.on("plugins_loaded", () => {
139//     const dash = editor.getPluginApi("dashboard");
140//     if (!dash) return;
141//     dash.registerSection("todo", async (ctx) => {
142//         // Pretend we read a TODO count from somewhere async.
143//         const count = 3;
144//         if (count === 0) {
145//             ctx.kv("status", "inbox zero", "ok");
146//             return;
147//         }
148//         ctx.kv("open", String(count), count > 5 ? "warn" : "value");
149//         ctx.text("    " + "see all".padEnd(10), { color: "muted" });
150//         ctx.text("open inbox", {
151//             color: "accent",
152//             bold: true,
153//             onClick: () => editor.executeAction("open_inbox"),
154//         });
155//         ctx.newline();
156//     });
157// });
158"#;
159
160/// `tsconfig.json` for the user's init.ts. Matches the plugin-dev
161/// workspace (no DOM, no ambient types) so LSP behaviour is consistent
162/// with plugins.
163const INIT_TSCONFIG: &str = r#"{
164  "compilerOptions": {
165    "target": "ES2020",
166    "module": "ES2020",
167    "moduleResolution": "node",
168    "strict": true,
169    "noEmit": true,
170    "skipLibCheck": true,
171    "lib": ["ES2020"],
172    "types": []
173  },
174  "files": ["init.ts", "types/fresh.d.ts", "types/plugins.d.ts"]
175}
176"#;
177
178/// Resolve the path to `fresh.d.ts` inside the embedded-plugins cache.
179/// Only embedded content is used — never an on-disk copy that isn't
180/// guaranteed to match this binary — so the types always track the
181/// running build.
182#[cfg(feature = "embed-plugins")]
183fn embedded_fresh_dts_path() -> Option<PathBuf> {
184    let embedded = crate::services::plugins::embedded::get_embedded_plugins_dir()?;
185    let p = embedded.join("lib").join("fresh.d.ts");
186    p.exists().then_some(p)
187}
188
189#[cfg(not(feature = "embed-plugins"))]
190fn embedded_fresh_dts_path() -> Option<PathBuf> {
191    None
192}
193
194/// Refresh `~/.config/fresh/types/fresh.d.ts` from the embedded copy and
195/// write `tsconfig.json` if it isn't already present.
196///
197/// `fresh.d.ts` is **always overwritten** — it's an auto-generated API
198/// mirror that must track the running binary. Keeping a stale copy in
199/// `~/.config/fresh/types/` would silently hide drift between the API
200/// the user's `init.ts` was written against and the one the running
201/// binary actually exposes. `tsconfig.json` is treated as user-editable
202/// and only written on first run.
203///
204/// Errors are logged but not returned: type scaffolding is best-effort
205/// and must not block opening or loading init.ts.
206pub fn refresh_types_scaffolding(config_dir: &Path) {
207    let Some(source) = embedded_fresh_dts_path() else {
208        tracing::warn!(
209            "init.ts: embedded fresh.d.ts unavailable; \
210             LSP completions in init.ts will be unavailable"
211        );
212        return;
213    };
214
215    let types_dir = config_dir.join("types");
216    if let Err(e) = std::fs::create_dir_all(&types_dir) {
217        tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
218        return;
219    }
220    let dest_dts = types_dir.join("fresh.d.ts");
221    if let Err(e) = std::fs::copy(&source, &dest_dts) {
222        tracing::warn!(
223            "init.ts: failed to copy fresh.d.ts from {} to {}: {e}",
224            source.display(),
225            dest_dts.display()
226        );
227    }
228
229    let tsconfig = config_dir.join("tsconfig.json");
230    if !tsconfig.exists() {
231        if let Err(e) = std::fs::write(&tsconfig, INIT_TSCONFIG) {
232            tracing::warn!("init.ts: failed to write {}: {e}", tsconfig.display());
233        }
234    }
235}
236
237/// Write `<config_dir>/types/plugins.d.ts` from the `.d.ts` emit of
238/// each loaded plugin. The editor calls this after scanning every
239/// plugin directory, so by the time `init.ts` is evaluated the
240/// ambient `FreshPluginRegistry` is fully populated and
241/// `editor.getPluginApi("dashboard")` resolves to the typed overload.
242///
243/// Errors are logged but not returned: an empty or stale
244/// `plugins.d.ts` must not block startup.
245pub fn write_plugin_declarations(config_dir: &Path, declarations: &[(String, String)]) {
246    let types_dir = config_dir.join("types");
247    if let Err(e) = std::fs::create_dir_all(&types_dir) {
248        tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
249        return;
250    }
251    let dest = types_dir.join("plugins.d.ts");
252
253    // Stable order so a re-scan doesn't produce a needlessly
254    // different file (and a noisy diff for users who version-control
255    // their config dir).
256    let mut sorted: Vec<&(String, String)> = declarations.iter().collect();
257    sorted.sort_by(|a, b| a.0.cmp(&b.0));
258
259    let mut body = String::new();
260    body.push_str(
261        "// AUTO-GENERATED by fresh — do not edit.\n\
262         //\n\
263         // Aggregate of every loaded plugin's isolated-declarations\n\
264         // emit (oxc). This is what makes `editor.getPluginApi(\"foo\")`\n\
265         // return a typed result in init.ts / downstream plugins —\n\
266         // each plugin that declares `FreshPluginRegistry` here\n\
267         // contributes its augmentation.\n\n",
268    );
269    for (name, dts) in sorted {
270        let trimmed = dts.trim();
271        // Script-style plugins with no exports get `export {};` appended
272        // in the parser to force module mode. After isolated-declarations
273        // strips internals, that's all that remains — a per-plugin
274        // section with just `export {};` is pure noise in the aggregate.
275        if trimmed.is_empty() || trimmed == "export {};" {
276            continue;
277        }
278        body.push_str(&format!("// ── {name} ─────────────────────\n"));
279        body.push_str(dts.trim_end());
280        body.push_str("\n\n");
281    }
282
283    if let Err(e) = std::fs::write(&dest, &body) {
284        tracing::warn!("init.ts: failed to write {}: {e}", dest.display());
285    }
286}
287
288/// Ensure `~/.config/fresh/init.ts` exists. If absent, writes the starter
289/// template. Also refreshes `types/fresh.d.ts` + `tsconfig.json` so the
290/// template's `/// <reference path=...` directive resolves and
291/// `getEditor()` type-checks in any TS-aware editor.
292/// Returns the (possibly newly-created) `init.ts` path.
293pub fn ensure_starter(config_dir: &Path) -> std::io::Result<PathBuf> {
294    let path = init_ts_path(config_dir);
295    if !path.exists() {
296        if let Some(parent) = path.parent() {
297            std::fs::create_dir_all(parent)?;
298        }
299        std::fs::write(&path, STARTER_TEMPLATE)?;
300    }
301    refresh_types_scaffolding(config_dir);
302    Ok(path)
303}
304
305/// Outcome of [`autoload`].
306#[derive(Debug)]
307pub enum InitOutcome {
308    /// init.ts did not exist; nothing to do.
309    NotFound,
310    /// Skipped because `--no-init` / `--safe` was passed.
311    Disabled,
312    /// Skipped because the crash fuse engaged.
313    CrashFused { failures: u32 },
314    /// Loaded and evaluated successfully.
315    Loaded,
316    /// Evaluation produced an error; the status message has been set.
317    Failed { message: String },
318}
319
320/// Resolve `~/.config/fresh/init.ts`.
321pub fn init_ts_path(config_dir: &Path) -> PathBuf {
322    config_dir.join("init.ts")
323}
324
325/// Resolve the crash-fuse counter file path.
326fn crashes_path(config_dir: &Path) -> PathBuf {
327    config_dir.join("logs").join("init.crashes")
328}
329
330#[derive(Debug, Default)]
331struct CrashState {
332    count: u32,
333    last_increment_epoch: u64,
334}
335
336impl CrashState {
337    fn load(config_dir: &Path) -> Self {
338        let path = crashes_path(config_dir);
339        let Ok(text) = std::fs::read_to_string(&path) else {
340            return Self::default();
341        };
342        let mut count = 0u32;
343        let mut last = 0u64;
344        for (i, line) in text.lines().enumerate() {
345            let trimmed = line.trim();
346            if trimmed.is_empty() {
347                continue;
348            }
349            match i {
350                0 => count = trimmed.parse().unwrap_or(0),
351                1 => last = trimmed.parse().unwrap_or(0),
352                _ => break,
353            }
354        }
355        Self {
356            count,
357            last_increment_epoch: last,
358        }
359    }
360
361    fn save(&self, config_dir: &Path) -> std::io::Result<()> {
362        let path = crashes_path(config_dir);
363        if let Some(parent) = path.parent() {
364            std::fs::create_dir_all(parent)?;
365        }
366        std::fs::write(
367            &path,
368            format!("{}\n{}\n", self.count, self.last_increment_epoch),
369        )
370    }
371
372    fn clear(config_dir: &Path) {
373        let path = crashes_path(config_dir);
374        if let Err(e) = std::fs::remove_file(&path) {
375            if e.kind() != std::io::ErrorKind::NotFound {
376                tracing::debug!(
377                    "init.ts crash-fuse: failed to clear {}: {e}",
378                    path.display()
379                );
380            }
381        }
382    }
383}
384
385fn now_epoch_secs() -> u64 {
386    std::time::SystemTime::now()
387        .duration_since(std::time::UNIX_EPOCH)
388        .map(|d| d.as_secs())
389        .unwrap_or(0)
390}
391
392/// Called before loading init.ts. Returns `Some(failures)` if the fuse has
393/// tripped and init.ts should be skipped; `None` if loading may proceed.
394///
395/// Also increments the counter — if init.ts evaluation succeeds, the caller
396/// must invoke [`record_success`] to reset it.
397fn check_and_increment_fuse(config_dir: &Path) -> Option<u32> {
398    let now = now_epoch_secs();
399    let mut state = CrashState::load(config_dir);
400
401    // Stale entries outside the rolling window: treat as a clean slate.
402    if state.last_increment_epoch == 0
403        || now.saturating_sub(state.last_increment_epoch) > CRASH_FUSE_WINDOW_SECS
404    {
405        state.count = 0;
406    }
407
408    if state.count >= CRASH_FUSE_THRESHOLD {
409        return Some(state.count);
410    }
411
412    state.count += 1;
413    state.last_increment_epoch = now;
414    if let Err(e) = state.save(config_dir) {
415        tracing::debug!("init.ts crash-fuse: failed to persist counter: {e}");
416    }
417
418    None
419}
420
421/// Called after init.ts finishes cleanly. Resets the crash-fuse counter so
422/// the next launch starts from zero.
423pub fn record_success(config_dir: &Path) {
424    CrashState::clear(config_dir);
425}
426
427/// Read init.ts from disk. Returns `Ok(None)` when the file simply doesn't
428/// exist.
429pub fn read_init_script(config_dir: &Path) -> std::io::Result<Option<String>> {
430    let path = init_ts_path(config_dir);
431    match std::fs::read_to_string(&path) {
432        Ok(s) => Ok(Some(s)),
433        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
434        Err(e) => Err(e),
435    }
436}
437
438/// Decide, without touching disk for the source, whether init.ts loading
439/// should run at all.
440pub fn should_skip(enabled: bool) -> bool {
441    !enabled
442}
443
444/// Human-readable summary for the status bar / logs.
445pub fn describe(outcome: &InitOutcome) -> String {
446    match outcome {
447        InitOutcome::NotFound => String::from("init.ts: not present"),
448        InitOutcome::Disabled => String::from("init.ts: skipped (--no-init / --safe)"),
449        InitOutcome::CrashFused { failures } => format!(
450            "init.ts: skipped after {failures} consecutive failures — fix ~/.config/fresh/init.ts or remove it"
451        ),
452        InitOutcome::Loaded => String::from("init.ts: loaded"),
453        InitOutcome::Failed { message } => format!("init.ts: {message}"),
454    }
455}
456
457/// Pre-flight for the caller: check fuse, return either the source to load
458/// or an outcome explaining why we're not loading.
459pub enum LoadDecision {
460    Skip(InitOutcome),
461    Load { source: String },
462}
463
464pub fn decide_load(config_dir: &Path, enabled: bool) -> LoadDecision {
465    if should_skip(enabled) {
466        return LoadDecision::Skip(InitOutcome::Disabled);
467    }
468    match read_init_script(config_dir) {
469        Ok(None) => LoadDecision::Skip(InitOutcome::NotFound),
470        Err(e) => LoadDecision::Skip(InitOutcome::Failed {
471            message: format!("read failed: {e}"),
472        }),
473        Ok(Some(source)) => {
474            if let Some(failures) = check_and_increment_fuse(config_dir) {
475                LoadDecision::Skip(InitOutcome::CrashFused { failures })
476            } else {
477                LoadDecision::Load { source }
478            }
479        }
480    }
481}
482
483/// Result of `fresh --cmd init check`.
484#[derive(Debug)]
485pub struct CheckReport {
486    pub ok: bool,
487    pub diagnostics: Vec<CheckDiagnostic>,
488    pub path: PathBuf,
489}
490
491#[derive(Debug)]
492pub struct CheckDiagnostic {
493    pub severity: CheckSeverity,
494    pub message: String,
495    /// Best-effort: 1-based line number. `0` if the parser didn't surface one.
496    pub line: u32,
497    pub column: u32,
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub enum CheckSeverity {
502    Error,
503    Warning,
504}
505
506/// Parse `~/.config/fresh/init.ts` via oxc and report syntax errors.
507///
508/// This is the "parse mode" from the design (§5.1): always-on, low-latency,
509/// catches the mistakes that would otherwise blow up at startup. The
510/// deeper type-check (`tsc --noEmit`) and the scope-discipline lints
511/// (`init/unconditional-preference`, `init/unconditional-plugin-load`)
512/// are deliberately not implemented here — they're strict-mode concerns
513/// that can grow on top of this foundation.
514pub fn check(config_dir: &Path) -> CheckReport {
515    use oxc_allocator::Allocator;
516    use oxc_parser::Parser;
517    use oxc_span::SourceType;
518
519    let path = init_ts_path(config_dir);
520
521    let source = match std::fs::read_to_string(&path) {
522        Ok(s) => s,
523        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
524            return CheckReport {
525                ok: true,
526                diagnostics: Vec::new(),
527                path,
528            };
529        }
530        Err(e) => {
531            return CheckReport {
532                ok: false,
533                diagnostics: vec![CheckDiagnostic {
534                    severity: CheckSeverity::Error,
535                    message: format!("read failed: {e}"),
536                    line: 0,
537                    column: 0,
538                }],
539                path,
540            };
541        }
542    };
543
544    let allocator = Allocator::default();
545    let source_type = SourceType::from_path(&path).unwrap_or_default();
546    let parser_ret = Parser::new(&allocator, &source, source_type).parse();
547
548    let mut diagnostics = Vec::new();
549    for err in &parser_ret.errors {
550        // oxc errors carry labels/spans but the formatting is embedded in
551        // the miette-style Display impl. Pull the primary message + try to
552        // recover line/column from the start of the first label.
553        let (line, column) = err
554            .labels
555            .as_ref()
556            .and_then(|v| v.first())
557            .map(|l| line_col(&source, l.offset()))
558            .unwrap_or((0, 0));
559        diagnostics.push(CheckDiagnostic {
560            severity: CheckSeverity::Error,
561            message: err.message.to_string(),
562            line,
563            column,
564        });
565    }
566
567    CheckReport {
568        ok: parser_ret.errors.is_empty(),
569        diagnostics,
570        path,
571    }
572}
573
574/// Convert a byte offset into a (line, column) pair, 1-based, for display.
575fn line_col(source: &str, offset: usize) -> (u32, u32) {
576    let clipped = source.get(..offset).unwrap_or(source);
577    let line = 1 + clipped.bytes().filter(|&b| b == b'\n').count();
578    let col = 1 + clipped
579        .rsplit('\n')
580        .next()
581        .map(|s| s.chars().count())
582        .unwrap_or(0);
583    (line as u32, col as u32)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use tempfile::TempDir;
590
591    #[test]
592    fn init_ts_path_is_under_config_dir() {
593        let p = init_ts_path(Path::new("/tmp/fresh"));
594        assert_eq!(p, PathBuf::from("/tmp/fresh/init.ts"));
595    }
596
597    #[test]
598    fn crash_fuse_trips_after_threshold_consecutive_failures() {
599        let tmp = TempDir::new().unwrap();
600        let dir = tmp.path();
601
602        // Three attempts that never record success — each returns None
603        // (proceed) and bumps the counter.
604        for _ in 0..CRASH_FUSE_THRESHOLD {
605            assert!(check_and_increment_fuse(dir).is_none());
606        }
607
608        // Fourth attempt should be short-circuited.
609        let tripped = check_and_increment_fuse(dir);
610        assert!(tripped.is_some());
611        assert_eq!(tripped.unwrap(), CRASH_FUSE_THRESHOLD);
612    }
613
614    #[test]
615    fn record_success_resets_the_fuse() {
616        let tmp = TempDir::new().unwrap();
617        let dir = tmp.path();
618
619        for _ in 0..CRASH_FUSE_THRESHOLD {
620            check_and_increment_fuse(dir);
621        }
622        record_success(dir);
623
624        // After success, we should have room for another full cycle.
625        assert!(check_and_increment_fuse(dir).is_none());
626    }
627
628    #[test]
629    fn stale_failures_outside_window_are_ignored() {
630        let tmp = TempDir::new().unwrap();
631        let dir = tmp.path();
632
633        // Manually plant an old, tripped counter.
634        let state = CrashState {
635            count: CRASH_FUSE_THRESHOLD + 5,
636            last_increment_epoch: now_epoch_secs().saturating_sub(CRASH_FUSE_WINDOW_SECS + 1),
637        };
638        state.save(dir).unwrap();
639
640        // Next attempt should treat it as fresh: proceed, counter back to 1.
641        assert!(check_and_increment_fuse(dir).is_none());
642    }
643
644    #[test]
645    fn decide_load_reports_not_found_when_missing() {
646        let tmp = TempDir::new().unwrap();
647        match decide_load(tmp.path(), true) {
648            LoadDecision::Skip(InitOutcome::NotFound) => {}
649            other => panic!("expected NotFound, got {other:?}"),
650        }
651    }
652
653    #[test]
654    fn decide_load_reports_disabled_when_flag_says_so() {
655        let tmp = TempDir::new().unwrap();
656        std::fs::write(init_ts_path(tmp.path()), "// hi").unwrap();
657        match decide_load(tmp.path(), false) {
658            LoadDecision::Skip(InitOutcome::Disabled) => {}
659            other => panic!("expected Disabled, got {other:?}"),
660        }
661    }
662
663    #[test]
664    fn decide_load_returns_source_when_file_present_and_enabled() {
665        let tmp = TempDir::new().unwrap();
666        std::fs::write(init_ts_path(tmp.path()), "const x = 1;").unwrap();
667        match decide_load(tmp.path(), true) {
668            LoadDecision::Load { source } => assert_eq!(source, "const x = 1;"),
669            other => panic!("expected Load, got {other:?}"),
670        }
671    }
672
673    // Minor: LoadDecision/InitOutcome must be Debug to use in assertions.
674    impl std::fmt::Debug for LoadDecision {
675        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676            match self {
677                LoadDecision::Skip(o) => write!(f, "Skip({o:?})"),
678                LoadDecision::Load { source } => write!(f, "Load({} chars)", source.len()),
679            }
680        }
681    }
682
683    #[test]
684    fn check_no_file_is_ok() {
685        let tmp = TempDir::new().unwrap();
686        let report = check(tmp.path());
687        assert!(report.ok);
688        assert!(report.diagnostics.is_empty());
689    }
690
691    #[test]
692    fn check_clean_source_is_ok() {
693        let tmp = TempDir::new().unwrap();
694        std::fs::write(
695            init_ts_path(tmp.path()),
696            "const editor = getEditor();\neditor.setStatus('hi');\n",
697        )
698        .unwrap();
699        let report = check(tmp.path());
700        assert!(report.ok, "diagnostics: {:?}", report.diagnostics);
701    }
702
703    #[test]
704    fn check_syntax_error_reports_a_diagnostic() {
705        let tmp = TempDir::new().unwrap();
706        // Missing closing paren — unambiguous parse error.
707        std::fs::write(init_ts_path(tmp.path()), "function broken(\n").unwrap();
708        let report = check(tmp.path());
709        assert!(!report.ok);
710        assert!(!report.diagnostics.is_empty());
711        assert_eq!(report.diagnostics[0].severity, CheckSeverity::Error);
712    }
713
714    #[test]
715    fn starter_template_references_both_dts_files() {
716        assert!(
717            STARTER_TEMPLATE.contains(r#"/// <reference path="./types/fresh.d.ts" />"#),
718            "starter template must reference fresh.d.ts"
719        );
720        assert!(
721            STARTER_TEMPLATE.contains(r#"/// <reference path="./types/plugins.d.ts" />"#),
722            "starter template must reference plugins.d.ts so plugin APIs are typed"
723        );
724    }
725
726    #[test]
727    fn write_plugin_declarations_skips_empty_export_plugins() {
728        let tmp = TempDir::new().unwrap();
729        let decls = vec![
730            ("noop".to_string(), "export {};\n".to_string()),
731            ("blank".to_string(), "".to_string()),
732            (
733                "dashboard".to_string(),
734                "export type DashboardApi = { foo(): void; };\n\
735                 declare global { interface FreshPluginRegistry { dashboard: DashboardApi; } }\n\
736                 export {};\n"
737                    .to_string(),
738            ),
739        ];
740        write_plugin_declarations(tmp.path(), &decls);
741        let body = std::fs::read_to_string(tmp.path().join("types/plugins.d.ts")).unwrap();
742        assert!(
743            body.contains("// ── dashboard ─"),
744            "dashboard section missing: {body}"
745        );
746        assert!(
747            body.contains("DashboardApi"),
748            "dashboard API missing: {body}"
749        );
750        assert!(
751            !body.contains("// ── noop ─"),
752            "empty-export plugin should not get a section header: {body}"
753        );
754        assert!(
755            !body.contains("// ── blank ─"),
756            "blank-emit plugin should not get a section header: {body}"
757        );
758    }
759}