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}