flowscope_cli/server/
state.rs

1//! Shared application state for the server.
2//!
3//! This module defines the `AppState` struct that holds the server configuration,
4//! watched files, and schema metadata. State is shared across handlers via `Arc`.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::time::SystemTime;
9
10use anyhow::{Context, Result};
11#[cfg(feature = "templating")]
12use flowscope_core::TemplateConfig;
13use flowscope_core::{Dialect, FileSource, SchemaMetadata};
14use tokio::sync::RwLock;
15
16/// Server configuration derived from CLI arguments.
17#[derive(Debug, Clone)]
18pub struct ServerConfig {
19    /// SQL dialect for analysis
20    pub dialect: Dialect,
21    /// Directories to watch for SQL files
22    pub watch_dirs: Vec<PathBuf>,
23    /// Static files to serve (when not using watch directories)
24    pub static_files: Option<Vec<FileSource>>,
25    /// Database connection URL for live schema introspection
26    pub metadata_url: Option<String>,
27    /// Schema name filter for metadata provider
28    pub metadata_schema: Option<String>,
29    /// Port to listen on
30    pub port: u16,
31    /// Whether to open browser on startup
32    pub open_browser: bool,
33    /// Optional schema DDL file path
34    pub schema_path: Option<PathBuf>,
35    /// Default template configuration (from CLI flags)
36    #[cfg(feature = "templating")]
37    pub template_config: Option<TemplateConfig>,
38}
39
40/// Shared application state.
41pub struct AppState {
42    /// Server configuration
43    pub config: ServerConfig,
44    /// Watched SQL files (updated by file watcher)
45    pub files: RwLock<Vec<FileSource>>,
46    /// Schema metadata from DDL or database
47    pub schema: RwLock<Option<SchemaMetadata>>,
48    /// File modification times for change detection
49    pub mtimes: RwLock<HashMap<PathBuf, SystemTime>>,
50}
51
52impl AppState {
53    /// Create new application state, loading initial files and schema.
54    pub async fn new(config: ServerConfig) -> Result<Self> {
55        // Load files either from static_files or by scanning watch directories
56        let (files, mtimes) = if let Some(ref static_files) = config.static_files {
57            // Use static files directly, no mtimes needed (no watching)
58            (static_files.clone(), HashMap::new())
59        } else {
60            // Scan watch directories in a blocking thread pool
61            let watch_dirs = config.watch_dirs.clone();
62            let scan_result =
63                tokio::task::spawn_blocking(move || super::scan_sql_files(&watch_dirs))
64                    .await
65                    .context("File scan task was cancelled")?;
66            scan_result.context("Failed to scan SQL files")?
67        };
68        let file_count = files.len();
69
70        // Load schema from database if URL provided
71        let schema = Self::load_schema(&config).await?;
72
73        if file_count > 0 {
74            println!("flowscope: loaded {} SQL file(s)", file_count);
75        }
76
77        Ok(Self {
78            config,
79            files: RwLock::new(files),
80            schema: RwLock::new(schema),
81            mtimes: RwLock::new(mtimes),
82        })
83    }
84
85    /// Load schema metadata from database connection.
86    ///
87    /// Uses `spawn_blocking` because `fetch_metadata_from_database` internally creates
88    /// a tokio runtime and blocks. Running blocking code on the async executor would
89    /// stall other tasks.
90    #[cfg(feature = "metadata-provider")]
91    async fn load_schema(config: &ServerConfig) -> Result<Option<SchemaMetadata>> {
92        if let Some(ref url) = config.metadata_url {
93            let url = url.clone();
94            let schema_filter = config.metadata_schema.clone();
95            let fetch_result = tokio::task::spawn_blocking(move || {
96                crate::metadata::fetch_metadata_from_database(&url, schema_filter)
97            })
98            .await
99            .context("Metadata fetch task was cancelled")?;
100            let schema = fetch_result.context("Failed to fetch database metadata")?;
101            println!("flowscope: loaded schema from database");
102            return Ok(Some(schema));
103        }
104        Self::load_schema_from_file(config)
105    }
106
107    #[cfg(not(feature = "metadata-provider"))]
108    async fn load_schema(config: &ServerConfig) -> Result<Option<SchemaMetadata>> {
109        Self::load_schema_from_file(config)
110    }
111
112    fn load_schema_from_file(config: &ServerConfig) -> Result<Option<SchemaMetadata>> {
113        if let Some(ref path) = config.schema_path {
114            let schema = crate::schema::load_schema_from_ddl(path, config.dialect)?;
115            println!("flowscope: loaded schema from DDL file: {}", path.display());
116            return Ok(Some(schema));
117        }
118        Ok(None)
119    }
120
121    /// Reload files from watch directories.
122    ///
123    /// File scanning is performed in a blocking thread pool to avoid blocking
124    /// the async executor, which could delay other requests.
125    ///
126    /// Returns early for static files mode since there's nothing to reload.
127    pub async fn reload_files(&self) -> Result<()> {
128        // Static files don't reload - they were provided at startup
129        if self.config.static_files.is_some() {
130            return Ok(());
131        }
132
133        let watch_dirs = self.config.watch_dirs.clone();
134
135        // Run file scanning in a blocking thread pool since it does I/O
136        let scan_result = tokio::task::spawn_blocking(move || super::scan_sql_files(&watch_dirs))
137            .await
138            .context("File scan task was cancelled")?;
139        let (files, mtimes) = scan_result.context("Failed to scan SQL files")?;
140
141        let count = files.len();
142        *self.files.write().await = files;
143        *self.mtimes.write().await = mtimes;
144        println!("flowscope: reloaded {} SQL file(s)", count);
145        Ok(())
146    }
147}