Skip to main content

codewhale_config/
setup_state.rs

1//! Unified setup-state model for the v0.8.67 constitution-first setup lane
2//! (#3403).
3//!
4//! This is the single record every setup step (#3404–#3412) reads and writes so
5//! that "configured", "skipped", "verified", and "ready" mean the same thing
6//! everywhere. It is persisted as a JSON sidecar (`setup_state.json`) under
7//! `$CODEWHALE_HOME`, written atomically through [`crate::persistence`] so it is
8//! independent of `config.toml`'s comment-preserving writes and can never leave
9//! a half-written file.
10//!
11//! The record holds two things:
12//!
13//! 1. A per-[`SetupStep`] [`StepEntry`] (status, required, safe summary,
14//!    writing lane version).
15//! 2. The constitution-first fields the wizard, the update checkpoint, and
16//!    `/constitution` all coordinate on.
17//!
18//! Readiness is a *derived* property ([`first_run_ready`](SetupState::first_run_ready)
19//! / [`update_ready`](SetupState::update_ready)); it is never persisted, so the
20//! rules can evolve without a migration.
21//!
22//! Secrets never appear here: [`StepEntry::result`] is a short human-facing
23//! summary (provider name, model id, mode name), never a key.
24
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28use anyhow::{Context, Result};
29use serde::{Deserialize, Serialize};
30
31use crate::persistence;
32
33/// Current schema version of the persisted setup-state record.
34pub const SETUP_STATE_SCHEMA_VERSION: u32 = 1;
35
36/// Filename of the setup-state sidecar under `$CODEWHALE_HOME`.
37pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
38
39/// Version of the *telemetry notice content* — not the app version.
40///
41/// The notice is owed whenever
42/// [`SetupState::telemetry_notice_decided_for`] does not match this string.
43/// Bumping it re-asks prior acceptors and unanswered users, so it is bumped only
44/// when the collection policy, schema, or disclosure materially changes. Prior
45/// declines remain off. Keying it to the app version would re-prompt every
46/// release, which is nagging with extra steps.
47pub const TELEMETRY_NOTICE_VERSION: &str = "3";
48
49/// Canonical setup step ids. The ordering matches the first-run spine so a
50/// `BTreeMap<SetupStep, _>` renders in wizard order.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum SetupStep {
54    /// Language first, so later screens and constitution prose are localized.
55    Language,
56    /// Provider + key (or local runtime) and a default model.
57    ProviderModel,
58    /// Trust, approvals, sandbox, network — runtime posture (#3406).
59    TrustSandbox,
60    /// User-global constitution choice / checkpoint.
61    Constitution,
62    /// Operate/Fleet readiness: provider auth, worker runtime, roster, and
63    /// concurrency review. Plan-limit detection remains a separate product
64    /// decision; this step only records reviewed current facts.
65    OperateFleet,
66    /// Hotbar shortcuts are optional, but now have a first-class setup card.
67    Hotbar,
68    /// Tools / MCP / skills / plugins (later lanes; tracked for completeness).
69    ToolsMcp,
70    /// Remote / mobile runtime (later lane; tracked for completeness).
71    RemoteRuntime,
72    /// Persistence paths for setup state, config, constitution, memory, and notes.
73    Persistence,
74    /// Final verification / doctor / ready summary.
75    Verification,
76}
77
78impl SetupStep {
79    /// All steps in canonical first-run order.
80    pub const ALL: [SetupStep; 10] = [
81        SetupStep::Language,
82        SetupStep::ProviderModel,
83        SetupStep::TrustSandbox,
84        SetupStep::Constitution,
85        SetupStep::OperateFleet,
86        SetupStep::Hotbar,
87        SetupStep::ToolsMcp,
88        SetupStep::RemoteRuntime,
89        SetupStep::Persistence,
90        SetupStep::Verification,
91    ];
92}
93
94/// Status of a single setup step. Shared vocabulary so `/setup`, `doctor`, and
95/// the context report never invent their own meanings.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum StepStatus {
99    /// Never visited.
100    NotStarted,
101    /// Suggested for a good first-run experience but not required.
102    Recommended,
103    /// Available but entirely optional.
104    Optional,
105    /// Intentionally postponed; surfaces in the report, does not block.
106    Deferred,
107    /// Currently being worked on.
108    InProgress,
109    /// Completed and checked (e.g. key validated, mode confirmed).
110    Verified,
111    /// Reached a usable-but-incomplete state needing user action
112    /// (e.g. a key that failed validation). Does not block the ready screen.
113    NeedsAction,
114    /// Attempted and errored.
115    Failed,
116    /// Explicitly skipped by the user.
117    Skipped,
118}
119
120impl StepStatus {
121    /// True for statuses that count as "the user dealt with this step" for the
122    /// purpose of reaching the ready screen.
123    #[must_use]
124    pub fn is_settled(self) -> bool {
125        matches!(
126            self,
127            StepStatus::Verified
128                | StepStatus::NeedsAction
129                | StepStatus::Deferred
130                | StepStatus::Optional
131                | StepStatus::Skipped
132        )
133    }
134}
135
136/// One persisted entry per setup step.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct StepEntry {
139    pub status: StepStatus,
140    /// Whether this step blocks "ready" for the lane that owns it. First-run and
141    /// update lanes differ; see the readiness helpers on [`SetupState`].
142    #[serde(default)]
143    pub required: bool,
144    /// Short, safe human-facing summary — provider name, model id, mode name,
145    /// health. **Never a secret.**
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub result: Option<String>,
148    /// Lane (e.g. `"0.8.67"`) that last wrote this entry, so staleness is
149    /// visible to `/setup`, `doctor`, and the context report.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub version: Option<String>,
152}
153
154impl StepEntry {
155    /// A freshly-visited entry written by `version`.
156    #[must_use]
157    pub fn new(status: StepStatus, required: bool, version: impl Into<String>) -> Self {
158        Self {
159            status,
160            required,
161            result: None,
162            version: Some(version.into()),
163        }
164    }
165
166    #[must_use]
167    pub fn with_result(mut self, result: impl Into<String>) -> Self {
168        self.result = Some(result.into());
169        self
170    }
171}
172
173/// The user's constitution decision. Every value except [`Unset`] counts as an
174/// explicit choice for readiness.
175///
176/// [`Unset`]: ConstitutionChoice::Unset
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ConstitutionChoice {
180    /// No decision recorded yet.
181    #[default]
182    Unset,
183    /// Accepted the bundled/default constitution floor. Creates no custom file.
184    Bundled,
185    /// Created a guided structured user-global constitution.
186    GuidedCustom,
187    /// Expert full-Markdown override
188    /// (`$CODEWHALE_HOME/prompts/constitution.md` + opt-in env).
189    ExpertOverride,
190    /// Explicitly postponed; bundled law applies until the user returns.
191    Deferred,
192}
193
194impl ConstitutionChoice {
195    /// True for any value other than [`Unset`](ConstitutionChoice::Unset).
196    #[must_use]
197    pub fn is_explicit(self) -> bool {
198        !matches!(self, ConstitutionChoice::Unset)
199    }
200}
201
202/// How the active custom constitution was authored. Recorded alongside
203/// [`ConstitutionChoice::GuidedCustom`] so `/setup`, `doctor`, and the report
204/// can show provenance without parsing free-text step results.
205///
206/// This is a *new optional field* rather than a new [`ConstitutionChoice`]
207/// variant so records written by this lane still load in older binaries
208/// (unknown fields are ignored on read; an unknown enum variant would fail the
209/// whole parse and force the inherited-state fallback).
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum ConstitutionAuthoring {
213    /// Deterministically rendered from the guided answers.
214    Guided,
215    /// Drafted by the user's configured model from the guided answers, then
216    /// schema-validated, bounded, previewed, and ratified. Advisory authorship
217    /// only — the drafting model gains no authority from having written it.
218    ModelDrafted,
219}
220
221/// Which constitution surface is currently the active user-global law.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum ConstitutionSource {
225    /// Only the bundled floor is active.
226    #[default]
227    Bundled,
228    /// A structured `constitution.json` under `$CODEWHALE_HOME`.
229    UserGlobal,
230    /// An expert full-Markdown override file.
231    ExpertOverride,
232}
233
234/// Validity of the active user-global constitution file, if any.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum ConstitutionValidity {
238    /// No custom file, or validity not yet evaluated.
239    #[default]
240    Unknown,
241    /// Parsed and usable.
242    Valid,
243    /// Present but failed to parse / structurally invalid.
244    Invalid,
245    /// Present but carried no usable policy.
246    Empty,
247    /// Present but could not be read.
248    Unreadable,
249}
250
251/// Where the current runtime posture came from. Mirrors the rule that a
252/// constitution may *recommend* posture but only an explicit config action
253/// (#3406) applies it.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum RuntimePostureSource {
257    /// Not yet reviewed.
258    #[default]
259    Unset,
260    /// Carried over from existing config without an explicit confirmation.
261    Inherited,
262    /// The user explicitly reviewed and confirmed the posture in setup.
263    Confirmed,
264}
265
266impl RuntimePostureSource {
267    /// True when posture has been inherited or confirmed (either satisfies
268    /// first-run readiness).
269    #[must_use]
270    pub fn is_reviewed(self) -> bool {
271        matches!(
272            self,
273            RuntimePostureSource::Inherited | RuntimePostureSource::Confirmed
274        )
275    }
276}
277
278/// The persisted, per-version setup-state record.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct SetupState {
281    pub schema_version: u32,
282
283    /// Per-step status entries.
284    #[serde(default)]
285    pub steps: BTreeMap<SetupStep, StepEntry>,
286
287    // ── Constitution-first fields ───────────────────────────────────────
288    /// The user's constitution decision.
289    #[serde(default)]
290    pub constitution_choice: ConstitutionChoice,
291    /// Lane version (e.g. `"0.8.67"`) whose constitution checkpoint the user has
292    /// completed. Drives the once-per-version update checkpoint (#3794).
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub constitution_checkpoint_completed_for: Option<String>,
295    /// Language the constitution prose was authored/reviewed in.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub constitution_language: Option<String>,
298    /// Which surface is the active user-global law.
299    #[serde(default)]
300    pub constitution_source: ConstitutionSource,
301    /// Validity of the active user-global constitution file.
302    #[serde(default)]
303    pub constitution_validity: ConstitutionValidity,
304    /// How the active custom constitution was authored (guided deterministic
305    /// vs model-drafted-then-ratified). `None` for bundled/deferred/inherited.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub constitution_authoring: Option<ConstitutionAuthoring>,
308    /// Stable content hash of the most recently previewed/accepted rendered
309    /// constitution (see [`crate::user_constitution::UserConstitution::preview_hash`]).
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub constitution_preview_hash: Option<String>,
312    /// Monotonic counter bumped each time a custom constitution is saved, so the
313    /// report and `/constitution` can show which revision is live.
314    #[serde(default)]
315    pub constitution_preview_version: u32,
316    /// Where the current runtime posture came from.
317    #[serde(default)]
318    pub runtime_posture_source: RuntimePostureSource,
319
320    /// Host-enforced Workflow dispatch and terminal receipts have been proven
321    /// for this installation. Older records did not carry this proof and must
322    /// deserialize false even if their Operate/Fleet card was marked Verified.
323    #[serde(default, skip_serializing_if = "is_false")]
324    pub operate_receipts_verified: bool,
325
326    /// True when this record was *derived* from existing config rather than
327    /// persisted by an explicit setup run. Lets `/setup` and `doctor` explain
328    /// why an updating user is not treated as a broken fresh install.
329    #[serde(default, skip_serializing_if = "is_false")]
330    pub inherited: bool,
331
332    // ── Telemetry notice ────────────────────────────────────────────────
333    /// [`TELEMETRY_NOTICE_VERSION`] whose telemetry notice the user has
334    /// answered. `None` means the notice is still owed.
335    ///
336    /// Never auto-completed and never deferred-completed: unlike the
337    /// constitution checkpoint, which records a `Deferred` completion on the
338    /// skip-onboarding path, a telemetry notice that was not rendered and
339    /// answered leaves this `None`. Collection follows the documented default
340    /// while the notice remains owed on the next interactive launch.
341    ///
342    /// These are *fields* rather than a new [`SetupStep`] variant on purpose:
343    /// an unknown enum variant fails the whole record parse and silently drops
344    /// the user back to derived-inherited state — including their constitution
345    /// checkpoint — while unknown fields are ignored.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub telemetry_notice_decided_for: Option<String>,
348    /// The user's answer to the notice. `false` with any recorded notice
349    /// version is a durable opt-out; `true` records acknowledgment of that
350    /// version's disclosure.
351    #[serde(default, skip_serializing_if = "is_false")]
352    pub telemetry_opt_in: bool,
353}
354
355#[allow(clippy::trivially_copy_pass_by_ref)]
356fn is_false(b: &bool) -> bool {
357    !*b
358}
359
360impl Default for SetupState {
361    fn default() -> Self {
362        Self {
363            schema_version: SETUP_STATE_SCHEMA_VERSION,
364            steps: BTreeMap::new(),
365            constitution_choice: ConstitutionChoice::default(),
366            constitution_checkpoint_completed_for: None,
367            constitution_language: None,
368            constitution_source: ConstitutionSource::default(),
369            constitution_validity: ConstitutionValidity::default(),
370            constitution_authoring: None,
371            constitution_preview_hash: None,
372            constitution_preview_version: 0,
373            runtime_posture_source: RuntimePostureSource::default(),
374            operate_receipts_verified: false,
375            inherited: false,
376            telemetry_notice_decided_for: None,
377            telemetry_opt_in: false,
378        }
379    }
380}
381
382/// Observable, secret-free facts about existing config used to derive a safe
383/// inherited setup-state for users who upgrade without a `setup_state.json`.
384///
385/// The caller (TUI/CLI) gathers these from `ConfigToml`, the trust marker, and
386/// the constitution files; keeping them as plain data keeps this module pure and
387/// unit-testable.
388#[derive(Debug, Clone, Default)]
389pub struct InheritedConfigFacts {
390    /// A provider/model route is configured.
391    pub has_provider_route: bool,
392    /// A key or local runtime is available (presence only — never the value).
393    pub has_credentials_or_local_runtime: bool,
394    /// The user has previously made a trust/approval decision.
395    pub trust_chosen: bool,
396    /// Onboarding language, if known.
397    pub language: Option<String>,
398    /// A structured user-global `constitution.json` exists.
399    pub has_user_constitution: bool,
400    /// An expert full-Markdown override is active.
401    pub has_expert_override: bool,
402    /// Validity of the user-global constitution, if present.
403    pub user_constitution_validity: ConstitutionValidity,
404}
405
406impl SetupState {
407    /// Status for a step, defaulting to [`StepStatus::NotStarted`].
408    #[must_use]
409    pub fn status(&self, step: SetupStep) -> StepStatus {
410        self.steps
411            .get(&step)
412            .map_or(StepStatus::NotStarted, |e| e.status)
413    }
414
415    /// Record (insert or replace) an entry for `step`.
416    pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
417        self.steps.insert(step, entry);
418        self
419    }
420
421    #[must_use]
422    fn step_verified(&self, step: SetupStep) -> bool {
423        self.status(step) == StepStatus::Verified
424    }
425
426    /// Provider/model is acceptable for first-run readiness when it is either
427    /// verified or in an actionable needs-action state (the EPIC keeps a
428    /// failed-key path reaching the ready screen).
429    #[must_use]
430    fn provider_model_ready_or_needs_action(&self) -> bool {
431        matches!(
432            self.status(SetupStep::ProviderModel),
433            StepStatus::Verified | StepStatus::NeedsAction
434        )
435    }
436
437    /// First-run "ready": language verified, provider/model ready-or-needs-action,
438    /// runtime posture inherited/confirmed, and an explicit constitution choice.
439    #[must_use]
440    pub fn first_run_ready(&self) -> bool {
441        self.step_verified(SetupStep::Language)
442            && self.provider_model_ready_or_needs_action()
443            && self.runtime_posture_source.is_reviewed()
444            && self.constitution_choice.is_explicit()
445    }
446
447    /// Operate/Fleet "ready": provider credentials are verified, runtime
448    /// posture has been reviewed, and the user has explicitly reviewed the
449    /// Fleet/Operate on-ramp. This is intentionally separate from
450    /// [`first_run_ready`](Self::first_run_ready): a local-first user can be
451    /// ready for ordinary first use before enabling durable multi-worker work.
452    #[must_use]
453    pub fn operate_ready(&self) -> bool {
454        self.first_run_ready()
455            && self.step_verified(SetupStep::ProviderModel)
456            && self.step_verified(SetupStep::OperateFleet)
457            && self.operate_receipts_verified
458    }
459
460    /// Update "ready" for `version`: the constitution checkpoint for that lane is
461    /// complete. Everything else is inherited from existing config.
462    #[must_use]
463    pub fn update_ready(&self, version: &str) -> bool {
464        self.constitution_checkpoint_completed_for.as_deref() == Some(version)
465    }
466
467    /// Whether the once-per-version update checkpoint should still be shown.
468    #[must_use]
469    pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
470        !self.update_ready(version)
471    }
472
473    /// Mark the constitution checkpoint complete for `version` (the bundled /
474    /// default path is a valid completion).
475    pub fn complete_constitution_checkpoint(
476        &mut self,
477        version: impl Into<String>,
478        choice: ConstitutionChoice,
479    ) -> &mut Self {
480        self.constitution_checkpoint_completed_for = Some(version.into());
481        self.constitution_choice = choice;
482        self
483    }
484
485    /// True when the telemetry notice for `version` has not been answered.
486    ///
487    /// A decision recorded against a *different* notice version does not
488    /// count: the content changed, so the answer is stale and is owed again.
489    #[must_use]
490    pub fn needs_telemetry_notice(&self, version: &str) -> bool {
491        self.telemetry_notice_decided_for.as_deref() != Some(version)
492    }
493
494    /// Record the user's answer to the telemetry notice for `version`.
495    ///
496    /// Call this only from a path where the notice was actually rendered and
497    /// the user actually answered. Deferral, skip-onboarding, and any
498    /// non-interactive surface must leave the record untouched.
499    pub fn record_telemetry_notice(
500        &mut self,
501        version: impl Into<String>,
502        opt_in: bool,
503    ) -> &mut Self {
504        self.telemetry_notice_decided_for = Some(version.into());
505        self.telemetry_opt_in = opt_in;
506        self
507    }
508
509    /// True when the user was asked the current notice and kept counting on.
510    #[must_use]
511    pub fn telemetry_accepted(&self, version: &str) -> bool {
512        !self.needs_telemetry_notice(version) && self.telemetry_opt_in
513    }
514
515    /// True when the user was asked the current notice and said no.
516    ///
517    /// Distinct from "never asked": only a recorded decline is an opt-out, and
518    /// only an opt-out may be acted on destructively.
519    #[must_use]
520    pub fn telemetry_declined(&self, version: &str) -> bool {
521        !self.needs_telemetry_notice(version) && !self.telemetry_opt_in
522    }
523
524    /// Whether any recorded telemetry notice was explicitly declined.
525    ///
526    /// Declines recorded by the former opt-in notice remain durable opt-outs
527    /// after telemetry becomes default-on. A notice-version bump may explain a
528    /// changed policy, but it must never erase a user's earlier "no".
529    #[must_use]
530    pub fn telemetry_opted_out(&self) -> bool {
531        self.telemetry_notice_decided_for.is_some() && !self.telemetry_opt_in
532    }
533
534    /// Derive a safe inherited state for an existing user with no persisted
535    /// `setup_state.json`. Surfaces they already configured become
536    /// [`StepStatus::Verified`]; an update never looks like a fresh, broken
537    /// setup. The constitution checkpoint is intentionally left incomplete so
538    /// updating users still see it once.
539    #[must_use]
540    pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
541        let mut state = SetupState {
542            inherited: true,
543            ..SetupState::default()
544        };
545        let inherited = "inherited";
546
547        if facts.language.is_some() {
548            state.set_step(
549                SetupStep::Language,
550                StepEntry::new(StepStatus::Verified, true, inherited),
551            );
552            state.constitution_language = facts.language.clone();
553        }
554
555        if facts.has_provider_route && facts.has_credentials_or_local_runtime {
556            state.set_step(
557                SetupStep::ProviderModel,
558                StepEntry::new(StepStatus::Verified, true, inherited),
559            );
560        } else if facts.has_provider_route {
561            state.set_step(
562                SetupStep::ProviderModel,
563                StepEntry::new(StepStatus::NeedsAction, true, inherited),
564            );
565        }
566
567        if facts.trust_chosen {
568            state.set_step(
569                SetupStep::TrustSandbox,
570                StepEntry::new(StepStatus::Verified, true, inherited),
571            );
572            state.runtime_posture_source = RuntimePostureSource::Inherited;
573        }
574
575        // Constitution: classify the active surface, but never auto-complete the
576        // checkpoint — the update lane requires the user to acknowledge it once.
577        if facts.has_expert_override {
578            state.constitution_source = ConstitutionSource::ExpertOverride;
579            state.constitution_choice = ConstitutionChoice::ExpertOverride;
580        } else if facts.has_user_constitution {
581            state.constitution_source = ConstitutionSource::UserGlobal;
582            state.constitution_validity = facts.user_constitution_validity;
583            if facts.user_constitution_validity == ConstitutionValidity::Valid {
584                state.constitution_choice = ConstitutionChoice::GuidedCustom;
585            }
586        } else {
587            state.constitution_source = ConstitutionSource::Bundled;
588        }
589
590        state
591    }
592
593    /// Path to the setup-state sidecar under `$CODEWHALE_HOME`.
594    pub fn path() -> Result<PathBuf> {
595        Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
596    }
597
598    /// Load the persisted setup-state from the home sidecar.
599    ///
600    /// Returns `Ok(None)` when the file is missing **or** unreadable/corrupt, so
601    /// callers fall back to [`derive_inherited`](Self::derive_inherited) rather
602    /// than forcing a fresh wizard. A corrupt record is logged, never fatal.
603    pub fn load() -> Result<Option<Self>> {
604        Ok(Self::load_from(&Self::path()?))
605    }
606
607    /// Load from an explicit path (testable). See [`load`](Self::load) for the
608    /// missing/corrupt fallback contract.
609    #[must_use]
610    pub fn load_from(path: &Path) -> Option<Self> {
611        let raw = match std::fs::read_to_string(path) {
612            Ok(raw) => raw,
613            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
614            Err(e) => {
615                tracing::warn!(
616                    target: "config::setup_state",
617                    "could not read {} ({e}); deriving status from existing config",
618                    path.display()
619                );
620                return None;
621            }
622        };
623        match serde_json::from_str::<SetupState>(&raw) {
624            Ok(state) => Some(state),
625            Err(e) => {
626                tracing::warn!(
627                    target: "config::setup_state",
628                    "{} is not a valid setup-state record ({e}); deriving status from existing config",
629                    path.display()
630                );
631                None
632            }
633        }
634    }
635
636    /// Atomically persist this record to the home sidecar.
637    pub fn save(&self) -> Result<()> {
638        let path = Self::path()?;
639        self.save_to(&path)
640    }
641
642    /// Atomically persist to an explicit path (testable).
643    pub fn save_to(&self, path: &Path) -> Result<()> {
644        persistence::atomic_write_json(path, self)
645            .with_context(|| format!("failed to persist setup state to {}", path.display()))
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    fn verified(version: &str) -> StepEntry {
654        StepEntry::new(StepStatus::Verified, true, version)
655    }
656
657    #[test]
658    fn default_is_not_first_run_ready() {
659        let state = SetupState::default();
660        assert!(!state.first_run_ready());
661        assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
662    }
663
664    #[test]
665    fn persistence_is_optional_before_verification() {
666        let persistence_index = SetupStep::ALL
667            .iter()
668            .position(|step| *step == SetupStep::Persistence)
669            .expect("persistence step");
670        let verification_index = SetupStep::ALL
671            .iter()
672            .position(|step| *step == SetupStep::Verification)
673            .expect("verification step");
674
675        assert!(persistence_index < verification_index);
676
677        let mut state = SetupState::default();
678        state.set_step(SetupStep::Language, verified("0.8.67"));
679        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
680        state.runtime_posture_source = RuntimePostureSource::Confirmed;
681        state.constitution_choice = ConstitutionChoice::Bundled;
682        assert!(state.first_run_ready());
683
684        state.set_step(
685            SetupStep::Persistence,
686            StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
687        );
688        assert!(state.first_run_ready());
689        assert!(!state.operate_ready());
690    }
691
692    #[test]
693    fn first_run_ready_requires_all_pillars() {
694        let mut state = SetupState::default();
695        state.set_step(SetupStep::Language, verified("0.8.67"));
696        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
697        state.runtime_posture_source = RuntimePostureSource::Confirmed;
698        // Still missing an explicit constitution choice.
699        assert!(!state.first_run_ready());
700        state.constitution_choice = ConstitutionChoice::Bundled;
701        assert!(state.first_run_ready());
702    }
703
704    #[test]
705    fn operate_ready_is_separate_from_first_run_ready() {
706        let mut state = SetupState::default();
707        state.set_step(SetupStep::Language, verified("0.8.67"));
708        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
709        state.runtime_posture_source = RuntimePostureSource::Confirmed;
710        state.constitution_choice = ConstitutionChoice::Bundled;
711        assert!(state.first_run_ready());
712        assert!(!state.operate_ready());
713
714        state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
715        assert!(
716            !state.operate_ready(),
717            "a legacy Verified card is not receipt proof"
718        );
719        state.operate_receipts_verified = true;
720        assert!(state.operate_ready());
721    }
722
723    #[test]
724    fn legacy_verified_operate_card_without_receipt_proof_fails_closed() {
725        let mut legacy = SetupState::default();
726        legacy.set_step(SetupStep::Language, verified("0.8.67"));
727        legacy.set_step(SetupStep::ProviderModel, verified("0.8.67"));
728        legacy.set_step(SetupStep::OperateFleet, verified("0.8.67"));
729        legacy.runtime_posture_source = RuntimePostureSource::Confirmed;
730        legacy.constitution_choice = ConstitutionChoice::Bundled;
731        let raw = serde_json::to_string(&legacy).expect("serialize legacy-style state");
732        assert!(!raw.contains("operate_receipts_verified"), "{raw}");
733
734        let loaded: SetupState = serde_json::from_str(&raw).expect("load legacy-style state");
735
736        assert_eq!(loaded.status(SetupStep::OperateFleet), StepStatus::Verified);
737        assert!(!loaded.operate_receipts_verified);
738        assert!(!loaded.operate_ready());
739    }
740
741    #[test]
742    fn operate_ready_requires_verified_provider_not_needs_action() {
743        let mut state = SetupState::default();
744        state.set_step(
745            SetupStep::ProviderModel,
746            StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
747        );
748        state.runtime_posture_source = RuntimePostureSource::Confirmed;
749        state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
750
751        assert!(!state.operate_ready());
752    }
753
754    #[test]
755    fn needs_action_provider_still_reaches_ready() {
756        let mut state = SetupState::default();
757        state.set_step(SetupStep::Language, verified("0.8.67"));
758        state.set_step(
759            SetupStep::ProviderModel,
760            StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
761        );
762        state.runtime_posture_source = RuntimePostureSource::Inherited;
763        state.constitution_choice = ConstitutionChoice::Deferred;
764        assert!(state.first_run_ready());
765    }
766
767    #[test]
768    fn deferred_constitution_counts_as_explicit_choice() {
769        assert!(ConstitutionChoice::Deferred.is_explicit());
770        assert!(ConstitutionChoice::Bundled.is_explicit());
771        assert!(!ConstitutionChoice::Unset.is_explicit());
772    }
773
774    #[test]
775    fn update_ready_tracks_checkpoint_version() {
776        let mut state = SetupState::default();
777        assert!(state.needs_constitution_checkpoint("0.8.67"));
778        state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
779        assert!(state.update_ready("0.8.67"));
780        assert!(!state.needs_constitution_checkpoint("0.8.67"));
781        // A later lane re-arms the checkpoint.
782        assert!(state.needs_constitution_checkpoint("0.8.68"));
783    }
784
785    #[test]
786    fn derive_inherited_marks_existing_user_safe() {
787        let facts = InheritedConfigFacts {
788            has_provider_route: true,
789            has_credentials_or_local_runtime: true,
790            trust_chosen: true,
791            language: Some("en".to_string()),
792            has_user_constitution: false,
793            has_expert_override: false,
794            user_constitution_validity: ConstitutionValidity::Unknown,
795        };
796        let state = SetupState::derive_inherited(&facts);
797        assert!(state.inherited);
798        assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
799        assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
800        assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
801        assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
802        // The update checkpoint must still be shown to an upgrading user.
803        assert!(state.needs_constitution_checkpoint("0.8.67"));
804    }
805
806    #[test]
807    fn derive_inherited_classifies_provider_without_key_as_needs_action() {
808        let facts = InheritedConfigFacts {
809            has_provider_route: true,
810            has_credentials_or_local_runtime: false,
811            ..InheritedConfigFacts::default()
812        };
813        let state = SetupState::derive_inherited(&facts);
814        assert_eq!(
815            state.status(SetupStep::ProviderModel),
816            StepStatus::NeedsAction
817        );
818    }
819
820    #[test]
821    fn derive_inherited_picks_up_existing_user_constitution() {
822        let facts = InheritedConfigFacts {
823            has_user_constitution: true,
824            user_constitution_validity: ConstitutionValidity::Valid,
825            ..InheritedConfigFacts::default()
826        };
827        let state = SetupState::derive_inherited(&facts);
828        assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
829        assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
830        assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
831    }
832
833    #[test]
834    fn round_trips_through_json_sidecar() {
835        let tmp = tempfile::tempdir().unwrap();
836        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
837
838        let mut state = SetupState::default();
839        state.set_step(
840            SetupStep::ProviderModel,
841            verified("0.8.67").with_result("openai · mimo-ultraspeed"),
842        );
843        state.constitution_choice = ConstitutionChoice::GuidedCustom;
844        state.constitution_preview_version = 3;
845        state.save_to(&path).unwrap();
846
847        let loaded = SetupState::load_from(&path).expect("record should load");
848        assert_eq!(loaded, state);
849        // Enum keys serialize as snake_case strings.
850        let raw = std::fs::read_to_string(&path).unwrap();
851        assert!(raw.contains("\"provider_model\""), "{raw}");
852        assert!(raw.contains("openai · mimo-ultraspeed"));
853    }
854
855    #[test]
856    fn constitution_authoring_round_trips_and_stays_optional() {
857        let tmp = tempfile::tempdir().unwrap();
858        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
859
860        let state = SetupState {
861            constitution_choice: ConstitutionChoice::GuidedCustom,
862            constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
863            ..Default::default()
864        };
865        state.save_to(&path).unwrap();
866
867        let loaded = SetupState::load_from(&path).expect("record should load");
868        assert_eq!(
869            loaded.constitution_authoring,
870            Some(ConstitutionAuthoring::ModelDrafted)
871        );
872        let raw = std::fs::read_to_string(&path).unwrap();
873        assert!(raw.contains("\"model_drafted\""), "{raw}");
874    }
875
876    #[test]
877    fn record_without_authoring_field_still_loads() {
878        // Records written before the model-drafting lane carry no
879        // constitution_authoring key; they must load with None, not fail.
880        let tmp = tempfile::tempdir().unwrap();
881        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
882        std::fs::write(
883            &path,
884            r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
885        )
886        .unwrap();
887        let loaded = SetupState::load_from(&path).expect("legacy record should load");
888        assert_eq!(loaded.constitution_authoring, None);
889        assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
890    }
891
892    #[test]
893    fn corrupt_record_falls_back_to_none() {
894        let tmp = tempfile::tempdir().unwrap();
895        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
896        std::fs::write(&path, "{ not valid json").unwrap();
897        assert!(SetupState::load_from(&path).is_none());
898    }
899
900    #[test]
901    fn missing_record_is_none_not_error() {
902        let tmp = tempfile::tempdir().unwrap();
903        let path = tmp.path().join("does-not-exist.json");
904        assert!(SetupState::load_from(&path).is_none());
905    }
906
907    #[test]
908    fn step_result_carries_no_secret_by_construction() {
909        // The result field is a caller-supplied safe summary; this documents the
910        // contract that callers pass names, not keys.
911        let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
912        let json = serde_json::to_string(&entry).unwrap();
913        assert!(!json.to_lowercase().contains("sk-"));
914    }
915}