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