1use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::state::{app_meta_dir, config_file_path};
12
13pub const SETUP_STATE_VERSION: u32 = 5;
14
15pub const REQUIRED_SETUP_STEPS: [&str; 8] = [
19 "welcome",
20 "storage",
21 "control",
22 "device",
23 "inference",
24 "relations", "care",
26 "ready",
27];
28
29pub const PROGRESSIVE_SETUP_PATHS: [&str; 6] = [
32 "reachability", "assurance", "people_connections", "domains_mail", "care_records", "peer_agreements", ];
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
46pub struct DeviceContext {
47 #[serde(default)]
50 pub ownership: String,
51 #[serde(default)]
53 pub machine_fleet: String,
54 #[serde(default)]
56 pub user_scope: String,
57 #[serde(default)]
60 pub shared_setting: String,
61 #[serde(default)]
63 pub notes: String,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
67pub struct SetupProfile {
68 #[serde(default)]
69 pub preferred_name: String,
70 #[serde(default)]
71 pub locale: String,
72 #[serde(default)]
73 pub timezone: String,
74 #[serde(default)]
75 pub accessibility_needs: Vec<String>,
76 #[serde(default)]
77 pub interests: Vec<String>,
78 #[serde(default)]
79 pub preferred_ontologies: Vec<String>,
80 #[serde(default)]
81 pub care_priorities: Vec<String>,
82 #[serde(default)]
83 pub qapp_goals: Vec<String>,
84 #[serde(default)]
86 pub device_context: DeviceContext,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct SetupState {
91 pub version: u32,
92 #[serde(default)]
93 pub completed: bool,
94 #[serde(default = "default_current_step")]
95 pub current_step: String,
96 #[serde(default)]
97 pub completed_steps: Vec<String>,
98 #[serde(default)]
99 pub migrated_from_legacy_config: bool,
100 #[serde(default)]
101 pub profile: SetupProfile,
102}
103
104fn default_current_step() -> String {
105 "welcome".to_string()
106}
107
108impl Default for SetupState {
109 fn default() -> Self {
110 Self {
111 version: SETUP_STATE_VERSION,
112 completed: false,
113 current_step: default_current_step(),
114 completed_steps: Vec::new(),
115 migrated_from_legacy_config: false,
116 profile: SetupProfile::default(),
117 }
118 }
119}
120
121pub fn update_setup_profile(profile: SetupProfile) -> Result<SetupState, String> {
122 let mut state = get_setup_state()?;
123 state.profile = profile;
124 save_setup_state_to(&setup_state_path(), &state)?;
125 let _ = crate::identity_plane::sync_local_device_context(&state.profile.device_context);
127 Ok(state)
128}
129
130pub fn setup_state_path() -> PathBuf {
131 app_meta_dir().join("setup-state.json")
132}
133
134pub fn get_setup_state() -> Result<SetupState, String> {
135 load_setup_state_from(&setup_state_path(), config_file_path().exists())
136}
137
138pub fn complete_setup_step(step: String) -> Result<SetupState, String> {
139 let step = step.trim();
140 let is_required = REQUIRED_SETUP_STEPS.contains(&step);
141 let is_progressive = PROGRESSIVE_SETUP_PATHS.contains(&step);
142 if !is_required && !is_progressive {
143 return Err(format!("Unknown setup step: {step}"));
144 }
145
146 let mut state = get_setup_state()?;
147 if state.completed && is_required {
149 return Ok(state);
150 }
151 if !state.completed_steps.iter().any(|done| done == step) {
152 state.completed_steps.push(step.to_string());
153 }
154 if !state.completed {
155 state.current_step = next_incomplete_step(&state).unwrap_or("ready").to_string();
156 }
157 save_setup_state_to(&setup_state_path(), &state)?;
158 Ok(state)
159}
160
161pub fn finish_setup() -> Result<SetupState, String> {
162 let mut state = get_setup_state()?;
163 let missing: Vec<&str> = REQUIRED_SETUP_STEPS
164 .iter()
165 .copied()
166 .filter(|required| {
167 !state
168 .completed_steps
169 .iter()
170 .any(|completed| completed == required)
171 })
172 .collect();
173 if !missing.is_empty() {
174 return Err(format!(
175 "Complete the required setup steps first: {}",
176 missing.join(", ")
177 ));
178 }
179
180 state.completed = true;
181 state.current_step = "complete".to_string();
182 save_setup_state_to(&setup_state_path(), &state)?;
183 crate::identity_plane::ensure_local_apparatus(Some(state.profile.device_context.clone()))?;
186 Ok(state)
187}
188
189fn next_incomplete_step(state: &SetupState) -> Option<&'static str> {
190 REQUIRED_SETUP_STEPS.iter().copied().find(|required| {
191 !state
192 .completed_steps
193 .iter()
194 .any(|completed| completed == required)
195 })
196}
197
198fn load_setup_state_from(path: &Path, legacy_config_exists: bool) -> Result<SetupState, String> {
199 match fs::read_to_string(path) {
200 Ok(text) => {
201 let mut state: SetupState = serde_json::from_str(&text)
202 .map_err(|error| format!("Could not read setup state: {error}"))?;
203 state.version = SETUP_STATE_VERSION;
204 Ok(state)
205 }
206 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
207 if legacy_config_exists {
208 let state = SetupState {
209 completed: true,
210 current_step: "complete".to_string(),
211 completed_steps: REQUIRED_SETUP_STEPS
212 .iter()
213 .map(|step| (*step).to_string())
214 .collect(),
215 migrated_from_legacy_config: true,
216 ..SetupState::default()
217 };
218 save_setup_state_to(path, &state)?;
219 Ok(state)
220 } else {
221 Ok(SetupState::default())
222 }
223 }
224 Err(error) => Err(format!("Could not read setup state: {error}")),
225 }
226}
227
228fn save_setup_state_to(path: &Path, state: &SetupState) -> Result<(), String> {
229 if let Some(parent) = path.parent() {
230 fs::create_dir_all(parent)
231 .map_err(|error| format!("Could not create setup directory: {error}"))?;
232 }
233 let json = serde_json::to_string_pretty(state)
234 .map_err(|error| format!("Could not encode setup state: {error}"))?;
235 fs::write(path, json).map_err(|error| format!("Could not save setup state: {error}"))
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn new_install_starts_at_welcome() {
244 let dir = tempfile::tempdir().unwrap();
245 let state = load_setup_state_from(&dir.path().join("setup.json"), false).unwrap();
246 assert!(!state.completed);
247 assert_eq!(state.current_step, "welcome");
248 assert!(state.completed_steps.is_empty());
249 }
250
251 #[test]
252 fn legacy_config_is_migrated_without_showing_the_gate() {
253 let dir = tempfile::tempdir().unwrap();
254 let path = dir.path().join("setup.json");
255 let state = load_setup_state_from(&path, true).unwrap();
256 assert!(state.completed);
257 assert!(state.migrated_from_legacy_config);
258 assert!(path.exists());
259 }
260
261 #[test]
262 fn required_step_order_is_deterministic() {
263 let mut state = SetupState::default();
264 assert_eq!(next_incomplete_step(&state), Some("welcome"));
265 state.completed_steps.push("welcome".into());
266 assert_eq!(next_incomplete_step(&state), Some("storage"));
267 state.completed_steps.push("storage".into());
268 for step in REQUIRED_SETUP_STEPS.iter().skip(2) {
269 state.completed_steps.push((*step).into());
270 }
271 assert_eq!(next_incomplete_step(&state), None);
272 }
273
274 #[test]
275 fn first_run_gate_does_not_require_relational_paths() {
276 assert!(!REQUIRED_SETUP_STEPS.contains(&"reachability"));
277 assert!(!REQUIRED_SETUP_STEPS.contains(&"assurance"));
278 assert!(PROGRESSIVE_SETUP_PATHS.contains(&"reachability"));
279 assert!(PROGRESSIVE_SETUP_PATHS.contains(&"people_connections"));
280 assert_eq!(REQUIRED_SETUP_STEPS.len(), 8);
281 }
282
283 #[test]
284 fn older_setup_state_gets_an_empty_profile() {
285 let state: SetupState = serde_json::from_str(
286 r#"{"version":2,"completed":false,"current_step":"care","completed_steps":[]}"#,
287 )
288 .unwrap();
289 assert_eq!(state.profile, SetupProfile::default());
290 assert_eq!(state.profile.device_context, DeviceContext::default());
291 }
292
293 #[test]
294 fn device_context_deserializes_on_v5_profile() {
295 let state: SetupState = serde_json::from_str(
296 r#"{
297 "version":5,
298 "completed":false,
299 "current_step":"device",
300 "completed_steps":[],
301 "profile":{
302 "device_context":{
303 "ownership":"owned_by_me",
304 "machine_fleet":"one_of_several",
305 "user_scope":"more_than_one",
306 "shared_setting":"family",
307 "notes":""
308 }
309 }
310 }"#,
311 )
312 .unwrap();
313 assert_eq!(state.profile.device_context.ownership, "owned_by_me");
314 assert_eq!(state.profile.device_context.shared_setting, "family");
315 }
316}