Skip to main content

logicaffeine_web/ui/
state.rs

1//! Global application state types.
2//!
3//! Defines state structures used across the application via Dioxus context.
4//! Includes license management, registry authentication, chat state, and
5//! Studio mode types.
6//!
7//! # Key Types
8//!
9//! | Type | Purpose |
10//! |------|---------|
11//! | [`LicenseState`] | Subscription validation and plan tiers |
12//! | [`RegistryAuthState`] | GitHub OAuth for package registry |
13//! | [`AppState`] | Chat history for the REPL interface |
14//! | [`StudioMode`] | Active mode in the Studio playground |
15//! | [`FileNode`] | Virtual file system tree for Studio |
16//! | [`MathDocument`] | Math mode formula collection |
17//!
18//! # License Tiers
19//!
20//! | Plan | Commercial Features | Z3 Verification |
21//! |------|---------------------|-----------------|
22//! | Free | No | No |
23//! | Supporter | No | No |
24//! | Pro | Yes | Yes |
25//! | Premium | Yes | Yes |
26//! | Lifetime | Yes | Yes |
27//! | Enterprise | Yes | Yes |
28
29use dioxus::prelude::*;
30use logicaffeine_language::{compile_with_options, CompileOptions, OutputFormat, Interner, socratic_explanation};
31
32/// API endpoint for license validation.
33const LICENSE_VALIDATOR_URL: &str = "https://api.logicaffeine.com/validate";
34
35/// Revalidation interval (24 hours in milliseconds).
36const VALIDATION_INTERVAL_MS: f64 = 24.0 * 60.0 * 60.0 * 1000.0;
37
38/// Subscription plan tiers.
39///
40/// Determines feature access and commercial usage rights.
41#[derive(Clone, PartialEq, Debug)]
42pub enum LicensePlan {
43    /// No active subscription.
44    None,
45    /// Free tier with basic features.
46    Free,
47    /// Supporter tier (paid, non-commercial).
48    Supporter,
49    /// Pro tier with commercial rights and Z3 verification.
50    Pro,
51    /// Premium tier with all features.
52    Premium,
53    /// One-time purchase with perpetual access.
54    Lifetime,
55    /// Organization license with team features.
56    Enterprise,
57}
58
59impl LicensePlan {
60    /// Parses a plan name from a string (case-insensitive).
61    pub fn from_str(s: &str) -> Self {
62        match s.to_lowercase().as_str() {
63            "free" => Self::Free,
64            "supporter" => Self::Supporter,
65            "pro" => Self::Pro,
66            "premium" => Self::Premium,
67            "lifetime" => Self::Lifetime,
68            "enterprise" => Self::Enterprise,
69            _ => Self::None,
70        }
71    }
72
73    /// Returns true if this plan includes commercial usage rights.
74    pub fn is_commercial(&self) -> bool {
75        matches!(self, Self::Pro | Self::Premium | Self::Lifetime | Self::Enterprise)
76    }
77
78    /// Returns true if this is a paid plan (not Free or None).
79    pub fn is_paid(&self) -> bool {
80        !matches!(self, Self::None | Self::Free)
81    }
82}
83
84/// License state management with async validation.
85///
86/// Stored in Dioxus context for app-wide access. Handles license key storage,
87/// periodic revalidation, and plan tier checks.
88#[derive(Clone, PartialEq)]
89pub struct LicenseState {
90    /// The license key (Stripe subscription ID).
91    pub key: Signal<Option<String>>,
92    /// Current subscription plan tier.
93    pub plan: Signal<LicensePlan>,
94    /// Whether the license has been validated.
95    pub is_valid: Signal<bool>,
96    /// Timestamp of last successful validation.
97    pub validated_at: Signal<Option<f64>>,
98    /// True while validation request is in flight.
99    pub is_validating: Signal<bool>,
100}
101
102impl LicenseState {
103    /// Creates a new license state, loading any existing data from LocalStorage.
104    pub fn new() -> Self {
105        let (key, plan, validated_at) = load_license_from_storage();
106
107        Self {
108            key: Signal::new(key),
109            plan: Signal::new(plan),
110            is_valid: Signal::new(false),
111            validated_at: Signal::new(validated_at),
112            is_validating: Signal::new(false),
113        }
114    }
115
116    pub fn has_license(&self) -> bool {
117        self.key.read().is_some()
118    }
119
120    pub fn is_commercial(&self) -> bool {
121        self.plan.read().is_commercial() && *self.is_valid.read()
122    }
123
124    pub fn needs_revalidation(&self) -> bool {
125        match *self.validated_at.read() {
126            Some(timestamp) => {
127                let now = js_sys::Date::now();
128                now - timestamp > VALIDATION_INTERVAL_MS
129            }
130            None => true,
131        }
132    }
133
134    pub fn set_license(&mut self, license_key: String, plan: LicensePlan) {
135        self.key.set(Some(license_key.clone()));
136        self.plan.set(plan.clone());
137        self.is_valid.set(true);
138        let now = js_sys::Date::now();
139        self.validated_at.set(Some(now));
140
141        save_license_to_storage(&license_key, &plan, now);
142    }
143
144    pub fn clear_license(&mut self) {
145        self.key.set(None);
146        self.plan.set(LicensePlan::None);
147        self.is_valid.set(false);
148        self.validated_at.set(None);
149
150        clear_license_from_storage();
151    }
152
153    pub async fn validate(&mut self) {
154        let license_key = match self.key.read().clone() {
155            Some(key) => key,
156            None => return,
157        };
158
159        self.is_validating.set(true);
160
161        match validate_license_async(&license_key).await {
162            Ok((is_valid, plan)) => {
163                self.is_valid.set(is_valid);
164                if is_valid {
165                    self.plan.set(plan);
166                    let now = js_sys::Date::now();
167                    self.validated_at.set(Some(now));
168                    save_license_to_storage(&license_key, &self.plan.read(), now);
169                }
170            }
171            Err(_) => {
172                self.is_valid.set(false);
173            }
174        }
175
176        self.is_validating.set(false);
177    }
178}
179
180async fn validate_license_async(license_key: &str) -> Result<(bool, LicensePlan), String> {
181    use gloo_net::http::Request;
182
183    let body = serde_json::json!({ "licenseKey": license_key });
184
185    let response = Request::post(LICENSE_VALIDATOR_URL)
186        .header("Content-Type", "application/json")
187        .body(body.to_string())
188        .map_err(|e| e.to_string())?
189        .send()
190        .await
191        .map_err(|e| e.to_string())?;
192
193    if !response.ok() {
194        return Ok((false, LicensePlan::None));
195    }
196
197    let data: serde_json::Value = response
198        .json()
199        .await
200        .map_err(|e| e.to_string())?;
201
202    let is_valid = data["valid"].as_bool().unwrap_or(false);
203    let plan_str = data["plan"].as_str().unwrap_or("none");
204    let plan = LicensePlan::from_str(plan_str);
205
206    Ok((is_valid, plan))
207}
208
209fn load_license_from_storage() -> (Option<String>, LicensePlan, Option<f64>) {
210    #[cfg(target_arch = "wasm32")]
211    {
212        if let Some(window) = web_sys::window() {
213            if let Ok(Some(storage)) = window.local_storage() {
214                let key = storage.get_item("logos_license_key").ok().flatten();
215                let plan_str = storage.get_item("logos_license_plan").ok().flatten().unwrap_or_default();
216                let validated_at = storage
217                    .get_item("logos_license_validated_at")
218                    .ok()
219                    .flatten()
220                    .and_then(|s| s.parse::<f64>().ok());
221
222                let plan = LicensePlan::from_str(&plan_str);
223                return (key, plan, validated_at);
224            }
225        }
226    }
227    (None, LicensePlan::None, None)
228}
229
230fn save_license_to_storage(key: &str, plan: &LicensePlan, validated_at: f64) {
231    #[cfg(target_arch = "wasm32")]
232    {
233        if let Some(window) = web_sys::window() {
234            if let Ok(Some(storage)) = window.local_storage() {
235                let _ = storage.set_item("logos_license_key", key);
236                let plan_str = format!("{:?}", plan).to_lowercase();
237                let _ = storage.set_item("logos_license_plan", &plan_str);
238                let _ = storage.set_item("logos_license_validated_at", &validated_at.to_string());
239            }
240        }
241    }
242    #[cfg(not(target_arch = "wasm32"))]
243    let _ = (key, plan, validated_at);
244}
245
246fn clear_license_from_storage() {
247    #[cfg(target_arch = "wasm32")]
248    {
249        if let Some(window) = web_sys::window() {
250            if let Ok(Some(storage)) = window.local_storage() {
251                let _ = storage.remove_item("logos_license_key");
252                let _ = storage.remove_item("logos_license_plan");
253                let _ = storage.remove_item("logos_license_validated_at");
254            }
255        }
256    }
257}
258
259/// A message in the REPL chat history.
260#[derive(Clone, PartialEq)]
261pub struct ChatMessage {
262    /// The sender of the message.
263    pub role: Role,
264    /// The message text (input or compiled output).
265    pub content: String,
266}
267
268/// Message sender role in the chat interface.
269#[derive(Clone, PartialEq)]
270pub enum Role {
271    /// User-entered input.
272    User,
273    /// Successful compilation result.
274    System,
275    /// Compilation error with Socratic guidance.
276    Error,
277}
278
279/// REPL chat state for the Landing page.
280///
281/// Maintains a history of user inputs and system responses.
282#[derive(Clone, Copy)]
283pub struct AppState {
284    history: Signal<Vec<ChatMessage>>,
285}
286
287impl AppState {
288    pub fn new() -> Self {
289        Self {
290            history: Signal::new(vec![ChatMessage {
291                role: Role::System,
292                content: "The Council is assembled. State your premise.".to_string(),
293            }]),
294        }
295    }
296
297    pub fn add_user_message(&mut self, text: String) {
298        self.history.write().push(ChatMessage {
299            role: Role::User,
300            content: text.clone(),
301        });
302        self.process_logic(text);
303    }
304
305    fn process_logic(&mut self, input: String) {
306        let options = CompileOptions { format: OutputFormat::Unicode, pragmatic: false };
307
308        let response = match compile_with_options(&input, options) {
309            Ok(logic) => ChatMessage {
310                role: Role::System,
311                content: logic,
312            },
313            Err(e) => {
314                let interner = Interner::new();
315                let advice = socratic_explanation(&e, &interner);
316                ChatMessage {
317                    role: Role::Error,
318                    content: advice,
319                }
320            }
321        };
322        self.history.write().push(response);
323    }
324
325    pub fn get_history(&self) -> Vec<ChatMessage> {
326        self.history.read().clone()
327    }
328}
329
330// ============================================================
331// Package Registry Authentication
332// ============================================================
333
334/// Base URL for the package registry API.
335const REGISTRY_API_URL: &str = "https://registry.logicaffeine.com";
336
337/// GitHub user profile data from OAuth.
338#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
339pub struct GitHubUser {
340    /// GitHub user ID (numeric string).
341    pub id: String,
342    /// GitHub username.
343    pub login: String,
344    /// Display name (may be empty).
345    pub name: Option<String>,
346    /// Profile picture URL.
347    pub avatar_url: Option<String>,
348}
349
350/// Authentication state for the package registry.
351///
352/// Manages GitHub OAuth tokens for publishing and starring packages.
353#[derive(Clone, PartialEq)]
354pub struct RegistryAuthState {
355    /// Currently authenticated GitHub user.
356    pub user: Signal<Option<GitHubUser>>,
357    /// OAuth access token for API calls.
358    pub token: Signal<Option<String>>,
359    /// True while authentication is in progress.
360    pub is_loading: Signal<bool>,
361}
362
363impl RegistryAuthState {
364    pub fn new() -> Self {
365        let (token, user) = load_registry_auth_from_storage();
366        Self {
367            user: Signal::new(user),
368            token: Signal::new(token),
369            is_loading: Signal::new(false),
370        }
371    }
372
373    pub fn is_authenticated(&self) -> bool {
374        self.token.read().is_some()
375    }
376
377    pub fn login(&mut self, token: String, user: GitHubUser) {
378        self.token.set(Some(token.clone()));
379        self.user.set(Some(user.clone()));
380        save_registry_auth_to_storage(&token, &user);
381    }
382
383    pub fn logout(&mut self) {
384        self.token.set(None);
385        self.user.set(None);
386        clear_registry_auth_from_storage();
387    }
388
389    pub fn get_auth_url() -> String {
390        format!("{}/auth/github", REGISTRY_API_URL)
391    }
392}
393
394fn load_registry_auth_from_storage() -> (Option<String>, Option<GitHubUser>) {
395    #[cfg(target_arch = "wasm32")]
396    {
397        if let Some(window) = web_sys::window() {
398            if let Ok(Some(storage)) = window.local_storage() {
399                let token = storage.get_item("logos_registry_token").ok().flatten();
400                let user_json = storage.get_item("logos_registry_user").ok().flatten();
401                let user = user_json.and_then(|j| serde_json::from_str(&j).ok());
402                return (token, user);
403            }
404        }
405    }
406    (None, None)
407}
408
409fn save_registry_auth_to_storage(token: &str, user: &GitHubUser) {
410    #[cfg(target_arch = "wasm32")]
411    {
412        if let Some(window) = web_sys::window() {
413            if let Ok(Some(storage)) = window.local_storage() {
414                let _ = storage.set_item("logos_registry_token", token);
415                if let Ok(json) = serde_json::to_string(user) {
416                    let _ = storage.set_item("logos_registry_user", &json);
417                }
418            }
419        }
420    }
421}
422
423fn clear_registry_auth_from_storage() {
424    #[cfg(target_arch = "wasm32")]
425    {
426        if let Some(window) = web_sys::window() {
427            if let Ok(Some(storage)) = window.local_storage() {
428                let _ = storage.remove_item("logos_registry_token");
429                let _ = storage.remove_item("logos_registry_user");
430            }
431        }
432    }
433}
434
435// ============================================================
436// Package Registry Types
437// ============================================================
438
439/// Summary information for a package listing.
440#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
441pub struct RegistryPackage {
442    /// Package name (unique identifier).
443    pub name: String,
444    /// Short description for search results.
445    pub description: Option<String>,
446    /// Most recent version number.
447    pub latest_version: Option<String>,
448    /// GitHub username of the package owner.
449    pub owner: String,
450    /// Owner's GitHub avatar URL.
451    pub owner_avatar: Option<String>,
452    /// Whether the package is officially verified.
453    pub verified: bool,
454    /// Total download count.
455    pub downloads: u64,
456    /// Searchable tags.
457    pub keywords: Vec<String>,
458}
459
460/// Metadata for a specific package version.
461#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
462pub struct PackageVersion {
463    /// Semantic version string (e.g., "1.0.0").
464    pub version: String,
465    /// ISO 8601 publication timestamp.
466    pub published_at: String,
467    /// Package size in bytes.
468    pub size: u64,
469    /// Whether this version has been yanked.
470    pub yanked: bool,
471}
472
473/// Full package detail for the package page.
474#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
475pub struct PackageDetails {
476    /// Package name.
477    pub name: String,
478    /// Full description.
479    pub description: Option<String>,
480    /// Package owner's GitHub username.
481    pub owner: String,
482    /// Owner's avatar URL.
483    pub owner_avatar: Option<String>,
484    /// Repository URL.
485    pub repository: Option<String>,
486    /// Documentation or homepage URL.
487    pub homepage: Option<String>,
488    /// SPDX license identifier.
489    pub license: Option<String>,
490    /// Searchable keywords.
491    pub keywords: Vec<String>,
492    /// Official verification status.
493    pub verified: bool,
494    /// Total downloads across all versions.
495    pub downloads: u64,
496    /// README content (markdown).
497    pub readme: Option<String>,
498    /// All published versions.
499    pub versions: Vec<PackageVersion>,
500}
501
502// ============================================================
503// Studio Mode State
504// ============================================================
505
506/// The active mode in the Studio playground.
507#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
508pub enum StudioMode {
509    /// English to First-Order Logic translation (default)
510    #[default]
511    Logic,
512    /// Vernacular REPL for proof development and .logos files
513    Code,
514    /// Visual formula builder with LaTeX preview
515    Math,
516    /// English hardware specs to SystemVerilog Assertions with in-browser,
517    /// kernel-certified proving (no Z3 in the bundle).
518    Hardware,
519}
520
521impl StudioMode {
522    /// Returns the file extension for this mode.
523    pub fn extension(&self) -> &'static str {
524        match self {
525            StudioMode::Logic => "logic",
526            StudioMode::Code => "logos",
527            StudioMode::Math => "math",
528            StudioMode::Hardware => "hw",
529        }
530    }
531
532    /// Returns the display name for this mode.
533    pub fn display_name(&self) -> &'static str {
534        match self {
535            StudioMode::Logic => "Logic",
536            StudioMode::Code => "Code",
537            StudioMode::Math => "Math",
538            StudioMode::Hardware => "Hardware",
539        }
540    }
541
542    /// Infer mode from file extension.
543    pub fn from_extension(ext: &str) -> Option<Self> {
544        match ext.to_lowercase().as_str() {
545            "logic" => Some(StudioMode::Logic),
546            "logos" => Some(StudioMode::Code),
547            "math" => Some(StudioMode::Math),
548            "hw" => Some(StudioMode::Hardware),
549            _ => None,
550        }
551    }
552}
553
554/// A node in the file tree representing a file or directory.
555#[derive(Clone, PartialEq, Debug)]
556pub struct FileNode {
557    /// Name of the file or directory (not full path).
558    pub name: String,
559    /// Full path from VFS root.
560    pub path: String,
561    /// True if this is a directory.
562    pub is_directory: bool,
563    /// Child nodes (empty for files).
564    pub children: Vec<FileNode>,
565    /// Whether this directory is expanded in the UI.
566    pub expanded: bool,
567}
568
569impl FileNode {
570    /// Create a new file node.
571    pub fn file(name: String, path: String) -> Self {
572        Self {
573            name,
574            path,
575            is_directory: false,
576            children: Vec::new(),
577            expanded: false,
578        }
579    }
580
581    /// Create a new directory node.
582    pub fn directory(name: String, path: String) -> Self {
583        Self {
584            name,
585            path,
586            is_directory: true,
587            children: Vec::new(),
588            expanded: true,
589        }
590    }
591
592    /// Create the root node for the file tree.
593    pub fn root() -> Self {
594        Self {
595            name: "workspace".to_string(),
596            path: "/".to_string(),
597            is_directory: true,
598            children: Vec::new(),
599            expanded: true,
600        }
601    }
602
603    /// Toggle the expanded state of a directory.
604    pub fn toggle_expanded(&mut self) {
605        if self.is_directory {
606            self.expanded = !self.expanded;
607        }
608    }
609
610    /// Find a node by path (mutable).
611    pub fn find_mut(&mut self, path: &str) -> Option<&mut FileNode> {
612        if self.path == path {
613            return Some(self);
614        }
615        for child in &mut self.children {
616            if let Some(found) = child.find_mut(path) {
617                return Some(found);
618            }
619        }
620        None
621    }
622}
623
624/// A line in the REPL output history.
625#[derive(Clone, PartialEq, Debug)]
626pub struct ReplLine {
627    /// The input command.
628    pub input: String,
629    /// The output (result or error).
630    pub output: Result<String, String>,
631    /// Whether this was executed successfully.
632    pub success: bool,
633}
634
635impl ReplLine {
636    pub fn success(input: String, output: String) -> Self {
637        Self {
638            input,
639            output: Ok(output),
640            success: true,
641        }
642    }
643
644    pub fn error(input: String, error: String) -> Self {
645        Self {
646            input,
647            output: Err(error),
648            success: false,
649        }
650    }
651}
652
653/// A formula entry for Math mode.
654#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
655pub struct MathFormula {
656    /// The LaTeX source.
657    pub latex: String,
658    /// Optional label/name for the formula.
659    pub label: Option<String>,
660}
661
662/// Math mode file format.
663#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
664pub struct MathDocument {
665    /// Document name.
666    pub name: String,
667    /// List of formulas.
668    pub formulas: Vec<MathFormula>,
669    /// Index of the currently active formula.
670    #[serde(default)]
671    pub active_index: usize,
672}
673
674impl Default for MathDocument {
675    fn default() -> Self {
676        Self {
677            name: "Untitled".to_string(),
678            formulas: vec![MathFormula {
679                latex: String::new(),
680                label: None,
681            }],
682            active_index: 0,
683        }
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn test_license_plan_from_str() {
693        assert_eq!(LicensePlan::from_str("free"), LicensePlan::Free);
694        assert_eq!(LicensePlan::from_str("FREE"), LicensePlan::Free);
695        assert_eq!(LicensePlan::from_str("Free"), LicensePlan::Free);
696        assert_eq!(LicensePlan::from_str("supporter"), LicensePlan::Supporter);
697        assert_eq!(LicensePlan::from_str("pro"), LicensePlan::Pro);
698        assert_eq!(LicensePlan::from_str("premium"), LicensePlan::Premium);
699        assert_eq!(LicensePlan::from_str("lifetime"), LicensePlan::Lifetime);
700        assert_eq!(LicensePlan::from_str("enterprise"), LicensePlan::Enterprise);
701        assert_eq!(LicensePlan::from_str("unknown"), LicensePlan::None);
702        assert_eq!(LicensePlan::from_str(""), LicensePlan::None);
703    }
704
705    #[test]
706    fn test_license_plan_is_commercial() {
707        assert!(!LicensePlan::None.is_commercial());
708        assert!(!LicensePlan::Free.is_commercial());
709        assert!(!LicensePlan::Supporter.is_commercial());
710        assert!(LicensePlan::Pro.is_commercial());
711        assert!(LicensePlan::Premium.is_commercial());
712        assert!(LicensePlan::Lifetime.is_commercial());
713        assert!(LicensePlan::Enterprise.is_commercial());
714    }
715
716    #[test]
717    fn test_license_plan_is_paid() {
718        assert!(!LicensePlan::None.is_paid());
719        assert!(!LicensePlan::Free.is_paid());
720        assert!(LicensePlan::Supporter.is_paid());
721        assert!(LicensePlan::Pro.is_paid());
722        assert!(LicensePlan::Premium.is_paid());
723        assert!(LicensePlan::Lifetime.is_paid());
724        assert!(LicensePlan::Enterprise.is_paid());
725    }
726
727    #[test]
728    fn test_studio_mode_hardware() {
729        // Hardware is a first-class Studio mode alongside Logic/Code/Math.
730        assert_eq!(StudioMode::Hardware.extension(), "hw");
731        assert_eq!(StudioMode::Hardware.display_name(), "Hardware");
732        assert_eq!(StudioMode::from_extension("hw"), Some(StudioMode::Hardware));
733        assert_eq!(StudioMode::from_extension("HW"), Some(StudioMode::Hardware));
734        // The existing modes keep resolving unchanged.
735        assert_eq!(StudioMode::from_extension("logic"), Some(StudioMode::Logic));
736        assert_eq!(StudioMode::from_extension("logos"), Some(StudioMode::Code));
737        assert_eq!(StudioMode::from_extension("math"), Some(StudioMode::Math));
738        assert_eq!(StudioMode::from_extension("txt"), None);
739    }
740}