Skip to main content

pi/core/platform/
first_run.rs

1//! First-run setup gating and persistence.
2//!
3//! Ports `cli/startup-ui.ts` `shouldRunFirstTimeSetup` plus the persistence
4//! half of `showFirstTimeSetup` from the TypeScript coding-agent. The
5//! interactive two-step dialog (theme, then analytics opt-in) is TUI-owned and
6//! intentionally not ported here; this module owns only the gate and the
7//! settings write.
8//!
9//! The gate runs only when **all** are true:
10//! 1. Official distribution (`@earendil-works/pi-coding-agent` / `pi` / `.pi`).
11//! 2. Experimental features enabled (`PI_EXPERIMENTAL == "1"`).
12//! 3. No agent-dir override (`PI_CODING_AGENT_DIR` unset).
13//! 4. No `settings.json` yet (first run).
14//!
15//! Persisting any selection writes `settings.json`, which suppresses future
16//! first-run prompts by failing gate 4.
17
18use crate::core::config::{
19    ENV_AGENT_DIR, get_agent_dir_with, get_settings_path_with, is_official_distribution,
20};
21use crate::core::experimental::are_experimental_features_enabled;
22use crate::core::settings::SettingsManager;
23
24/// A completed first-run selection ready to persist.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct FirstRunSelection {
27    /// Theme name to store under the `theme` setting.
28    pub theme: String,
29    /// Whether the user opted into anonymous usage analytics.
30    pub share_analytics: bool,
31}
32
33/// Pure gate predicate: whether first-run setup should run.
34///
35/// Takes each condition as an explicit argument so the decision is unit-testable
36/// on any host without touching the process environment or filesystem.
37#[must_use]
38pub fn should_run_first_time_setup(
39    official_distribution: bool,
40    experimental_enabled: bool,
41    agent_dir_override: Option<&str>,
42    settings_exists: bool,
43) -> bool {
44    official_distribution
45        && experimental_enabled
46        && agent_dir_override.is_none()
47        && !settings_exists
48}
49
50/// Host gate: resolve every condition from the process environment and disk.
51///
52/// `home_dir` and `settings_exists_override` are seams for tests; production
53/// passes [`None`] for both so home comes from `dirs` and existence is probed
54/// on disk.
55#[must_use]
56pub fn should_run_first_time_setup_on_host(
57    home_dir: Option<&std::path::Path>,
58    settings_exists_override: Option<bool>,
59) -> bool {
60    let agent_dir_env = std::env::var(ENV_AGENT_DIR).ok();
61    let settings_exists = settings_exists_override.unwrap_or_else(|| {
62        let agent_dir = get_agent_dir_with(agent_dir_env.as_deref(), home_dir);
63        get_settings_path_with(&agent_dir).exists()
64    });
65    should_run_first_time_setup(
66        is_official_distribution(),
67        are_experimental_features_enabled(),
68        agent_dir_env.as_deref(),
69        settings_exists,
70    )
71}
72
73/// Persist a first-run selection into global settings.
74///
75/// Writes `theme` and `enableAnalytics` (generating a tracking id on first
76/// opt-in via [`SettingsManager::set_enable_analytics`]) and flushes so the
77/// file lands on disk immediately, closing the first-run gate.
78///
79/// # Errors
80///
81/// Returns the underlying settings persistence error.
82pub fn persist_first_run_selection(
83    settings: &mut SettingsManager,
84    selection: &FirstRunSelection,
85) -> Result<(), String> {
86    settings.set_theme(&selection.theme);
87    settings.set_enable_analytics(selection.share_analytics);
88    settings.flush();
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn gate_requires_all_conditions() {
98        assert!(should_run_first_time_setup(true, true, None, false));
99
100        // Any single failing condition disables the gate.
101        assert!(!should_run_first_time_setup(false, true, None, false));
102        assert!(!should_run_first_time_setup(true, false, None, false));
103        assert!(!should_run_first_time_setup(
104            true,
105            true,
106            Some("/tmp/x"),
107            false
108        ));
109        assert!(!should_run_first_time_setup(true, true, None, true));
110        assert!(!should_run_first_time_setup(true, true, Some(""), false));
111    }
112
113    #[test]
114    fn empty_agent_dir_override_still_counts_as_unset() {
115        // An empty PI_CODING_AGENT_DIR is treated as an override (Some("")),
116        // matching Option::is_none semantics used by the reference's existence
117        // check.
118        assert!(!should_run_first_time_setup(true, true, Some(""), false));
119    }
120}