Skip to content

Commit 2f86da7

Browse files
authored
Merge pull request #107 from codervisor/copilot/implement-spec-186
Implement spec 186: Rust HTTP Server with Axum
2 parents ad9cb44 + 41ae23b commit 2f86da7

File tree

17 files changed

+3742
-79
lines changed

17 files changed

+3742
-79
lines changed

rust/Cargo.lock

Lines changed: 1288 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ members = [
33
"leanspec-core",
44
"leanspec-cli",
55
"leanspec-mcp",
6+
"leanspec-http",
67
]
78
resolver = "2"
89

rust/leanspec-http/Cargo.toml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
[package]
2+
name = "leanspec-http"
3+
version.workspace = true
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
authors.workspace = true
7+
license.workspace = true
8+
repository.workspace = true
9+
homepage.workspace = true
10+
description = "Rust HTTP server for LeanSpec UI using Axum"
11+
12+
[lib]
13+
name = "leanspec_http"
14+
path = "src/lib.rs"
15+
16+
[[bin]]
17+
name = "leanspec-http"
18+
path = "src/main.rs"
19+
20+
[dependencies]
21+
# Core library
22+
leanspec-core = { path = "../leanspec-core" }
23+
24+
# Web framework
25+
axum = "0.8.7"
26+
tower = "0.5.2"
27+
tower-http = { version = "0.6.8", features = ["cors", "trace", "fs"] }
28+
29+
# Async runtime
30+
tokio.workspace = true
31+
32+
# Serialization
33+
serde.workspace = true
34+
serde_json.workspace = true
35+
36+
# Date/time
37+
chrono.workspace = true
38+
39+
# Utilities
40+
thiserror.workspace = true
41+
42+
# Directories
43+
dirs = "5.0"
44+
45+
# Unique IDs
46+
uuid = { version = "1.10", features = ["v4"] }
47+
48+
# Tracing
49+
tracing = "0.1"
50+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
51+
52+
# CLI
53+
clap.workspace = true
54+
55+
# YAML parsing for config migration
56+
serde_yaml.workspace = true
57+
58+
[dev-dependencies]
59+
tempfile.workspace = true
60+
reqwest = { version = "0.12", features = ["json"] }
61+
tokio = { version = "1.42", features = ["full", "test-util"] }

rust/leanspec-http/src/config.rs

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
//! Configuration for the HTTP server
2+
//!
3+
//! Loads configuration from `~/.lean-spec/config.json`
4+
5+
use crate::error::ServerError;
6+
use serde::{Deserialize, Serialize};
7+
use std::fs;
8+
use std::path::PathBuf;
9+
10+
/// Server configuration
11+
#[derive(Debug, Clone, Serialize, Deserialize)]
12+
#[serde(rename_all = "camelCase")]
13+
pub struct ServerConfig {
14+
/// Server-specific configuration
15+
#[serde(default)]
16+
pub server: ServerSettings,
17+
18+
/// UI preferences
19+
#[serde(default)]
20+
pub ui: UiSettings,
21+
22+
/// Project management settings
23+
#[serde(default)]
24+
pub projects: ProjectSettings,
25+
}
26+
27+
impl Default for ServerConfig {
28+
fn default() -> Self {
29+
Self {
30+
server: ServerSettings::default(),
31+
ui: UiSettings::default(),
32+
projects: ProjectSettings::default(),
33+
}
34+
}
35+
}
36+
37+
/// Server-specific settings
38+
#[derive(Debug, Clone, Serialize, Deserialize)]
39+
#[serde(rename_all = "camelCase")]
40+
pub struct ServerSettings {
41+
/// Host to bind to (default: 127.0.0.1)
42+
#[serde(default = "default_host")]
43+
pub host: String,
44+
45+
/// Port to listen on (default: 3333)
46+
#[serde(default = "default_port")]
47+
pub port: u16,
48+
49+
/// CORS configuration
50+
#[serde(default)]
51+
pub cors: CorsSettings,
52+
}
53+
54+
impl Default for ServerSettings {
55+
fn default() -> Self {
56+
Self {
57+
host: default_host(),
58+
port: default_port(),
59+
cors: CorsSettings::default(),
60+
}
61+
}
62+
}
63+
64+
fn default_host() -> String {
65+
"127.0.0.1".to_string()
66+
}
67+
68+
fn default_port() -> u16 {
69+
3333
70+
}
71+
72+
/// CORS settings
73+
#[derive(Debug, Clone, Serialize, Deserialize)]
74+
#[serde(rename_all = "camelCase")]
75+
pub struct CorsSettings {
76+
/// Enable CORS (default: true)
77+
#[serde(default = "default_cors_enabled")]
78+
pub enabled: bool,
79+
80+
/// Allowed origins (default: localhost development ports)
81+
#[serde(default = "default_cors_origins")]
82+
pub origins: Vec<String>,
83+
}
84+
85+
impl Default for CorsSettings {
86+
fn default() -> Self {
87+
Self {
88+
enabled: default_cors_enabled(),
89+
origins: default_cors_origins(),
90+
}
91+
}
92+
}
93+
94+
fn default_cors_enabled() -> bool {
95+
true
96+
}
97+
98+
fn default_cors_origins() -> Vec<String> {
99+
vec![
100+
"http://localhost:5173".to_string(), // Vite dev server
101+
"http://localhost:3000".to_string(), // Next.js
102+
"http://localhost:3333".to_string(), // Self
103+
"http://127.0.0.1:5173".to_string(),
104+
"http://127.0.0.1:3000".to_string(),
105+
"http://127.0.0.1:3333".to_string(),
106+
"tauri://localhost".to_string(), // Tauri
107+
]
108+
}
109+
110+
/// UI preferences
111+
#[derive(Debug, Clone, Serialize, Deserialize)]
112+
#[serde(rename_all = "camelCase")]
113+
pub struct UiSettings {
114+
/// Theme (auto, light, dark)
115+
#[serde(default = "default_theme")]
116+
pub theme: String,
117+
118+
/// Locale (en, zh-CN)
119+
#[serde(default = "default_locale")]
120+
pub locale: String,
121+
122+
/// Compact mode
123+
#[serde(default)]
124+
pub compact_mode: bool,
125+
}
126+
127+
impl Default for UiSettings {
128+
fn default() -> Self {
129+
Self {
130+
theme: default_theme(),
131+
locale: default_locale(),
132+
compact_mode: false,
133+
}
134+
}
135+
}
136+
137+
fn default_theme() -> String {
138+
"auto".to_string()
139+
}
140+
141+
fn default_locale() -> String {
142+
"en".to_string()
143+
}
144+
145+
/// Project management settings
146+
#[derive(Debug, Clone, Serialize, Deserialize)]
147+
#[serde(rename_all = "camelCase")]
148+
pub struct ProjectSettings {
149+
/// Auto-discover projects in common locations
150+
#[serde(default = "default_auto_discover")]
151+
pub auto_discover: bool,
152+
153+
/// Maximum number of recent projects to track
154+
#[serde(default = "default_max_recent")]
155+
pub max_recent: usize,
156+
}
157+
158+
impl Default for ProjectSettings {
159+
fn default() -> Self {
160+
Self {
161+
auto_discover: default_auto_discover(),
162+
max_recent: default_max_recent(),
163+
}
164+
}
165+
}
166+
167+
fn default_auto_discover() -> bool {
168+
true
169+
}
170+
171+
fn default_max_recent() -> usize {
172+
10
173+
}
174+
175+
/// Get the LeanSpec config directory path
176+
pub fn config_dir() -> PathBuf {
177+
dirs::home_dir()
178+
.map(|h| h.join(".lean-spec"))
179+
.unwrap_or_else(|| PathBuf::from(".lean-spec"))
180+
}
181+
182+
/// Get the path to the config file
183+
pub fn config_path() -> PathBuf {
184+
config_dir().join("config.json")
185+
}
186+
187+
/// Get the path to the projects registry file
188+
pub fn projects_path() -> PathBuf {
189+
config_dir().join("projects.json")
190+
}
191+
192+
/// Load configuration from disk or return defaults
193+
pub fn load_config() -> Result<ServerConfig, ServerError> {
194+
let path = config_path();
195+
196+
if !path.exists() {
197+
// Try to migrate from YAML config
198+
let yaml_path = config_dir().join("config.yaml");
199+
if yaml_path.exists() {
200+
return migrate_yaml_config(&yaml_path);
201+
}
202+
203+
// Return defaults
204+
return Ok(ServerConfig::default());
205+
}
206+
207+
let content = fs::read_to_string(&path)
208+
.map_err(|e| ServerError::ConfigError(format!("Failed to read config: {}", e)))?;
209+
210+
serde_json::from_str(&content)
211+
.map_err(|e| ServerError::ConfigError(format!("Failed to parse config: {}", e)))
212+
}
213+
214+
/// Migrate from YAML config to JSON
215+
/// Note: This performs best-effort migration. Unknown YAML fields are ignored
216+
/// and defaults are used. The primary goal is to create a valid JSON config file.
217+
fn migrate_yaml_config(yaml_path: &PathBuf) -> Result<ServerConfig, ServerError> {
218+
let content = fs::read_to_string(yaml_path)
219+
.map_err(|e| ServerError::ConfigError(format!("Failed to read YAML config: {}", e)))?;
220+
221+
// Try to parse YAML directly into our config struct
222+
// This handles fields that match between YAML and JSON formats
223+
let config = serde_yaml::from_str::<ServerConfig>(&content).unwrap_or_else(|e| {
224+
tracing::warn!(
225+
"Could not fully parse YAML config, using defaults: {}",
226+
e
227+
);
228+
ServerConfig::default()
229+
});
230+
231+
// Save as JSON for future use
232+
if let Err(e) = save_config(&config) {
233+
tracing::warn!("Failed to save migrated config: {}", e);
234+
}
235+
236+
Ok(config)
237+
}
238+
239+
/// Save configuration to disk
240+
pub fn save_config(config: &ServerConfig) -> Result<(), ServerError> {
241+
let path = config_path();
242+
243+
// Ensure directory exists
244+
if let Some(parent) = path.parent() {
245+
fs::create_dir_all(parent)
246+
.map_err(|e| ServerError::ConfigError(format!("Failed to create config dir: {}", e)))?;
247+
}
248+
249+
let content = serde_json::to_string_pretty(config)
250+
.map_err(|e| ServerError::ConfigError(format!("Failed to serialize config: {}", e)))?;
251+
252+
fs::write(&path, content)
253+
.map_err(|e| ServerError::ConfigError(format!("Failed to write config: {}", e)))?;
254+
255+
Ok(())
256+
}
257+
258+
#[cfg(test)]
259+
mod tests {
260+
use super::*;
261+
262+
#[test]
263+
fn test_default_config() {
264+
let config = ServerConfig::default();
265+
assert_eq!(config.server.host, "127.0.0.1");
266+
assert_eq!(config.server.port, 3333);
267+
assert!(config.server.cors.enabled);
268+
assert_eq!(config.ui.theme, "auto");
269+
assert_eq!(config.projects.max_recent, 10);
270+
}
271+
272+
#[test]
273+
fn test_config_serialization() {
274+
let config = ServerConfig::default();
275+
let json = serde_json::to_string(&config).unwrap();
276+
let parsed: ServerConfig = serde_json::from_str(&json).unwrap();
277+
assert_eq!(parsed.server.port, config.server.port);
278+
}
279+
}

0 commit comments

Comments
 (0)