Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- P2-18: TOTP QR code local generation via qrcode lib (no external service) - P2-21: Suspend foreign LLM providers (OpenAI/Anthropic/Gemini) for early stage - P3-04: get_progress() now calculates actual percentage from completed/total steps - P3-05: saveSaaSSession calls now have .catch() error logging - P3-06: SaaS relay chatStream passes session_key/agent_id to backend - P3-02: Whiteboard unification plan document created Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
612 lines
20 KiB
Rust
612 lines
20 KiB
Rust
//! Kernel configuration
|
|
//!
|
|
//! Design principles:
|
|
//! - Model ID is passed directly to the API without any transformation
|
|
//! - No provider prefix or alias mapping
|
|
//! - Simple, unified configuration structure
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use serde::{Deserialize, Serialize};
|
|
use secrecy::SecretString;
|
|
use zclaw_types::Result;
|
|
use zclaw_runtime::{LlmDriver, AnthropicDriver, OpenAiDriver};
|
|
|
|
/// API protocol type
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ApiProtocol {
|
|
OpenAI,
|
|
Anthropic,
|
|
}
|
|
|
|
impl Default for ApiProtocol {
|
|
fn default() -> Self {
|
|
Self::OpenAI
|
|
}
|
|
}
|
|
|
|
/// LLM configuration - unified config for all providers
|
|
///
|
|
/// This is the single source of truth for LLM configuration.
|
|
/// Model ID is passed directly to the API without any transformation.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LlmConfig {
|
|
/// API base URL (e.g., "https://api.openai.com/v1")
|
|
pub base_url: String,
|
|
|
|
/// API key
|
|
#[serde(skip_serializing)]
|
|
pub api_key: String,
|
|
|
|
/// Model identifier - passed directly to the API
|
|
/// Examples: "gpt-4o", "glm-4-flash", "glm-4-plus", "claude-3-opus-20240229"
|
|
pub model: String,
|
|
|
|
/// API protocol (OpenAI-compatible or Anthropic)
|
|
#[serde(default)]
|
|
pub api_protocol: ApiProtocol,
|
|
|
|
/// Maximum tokens per response
|
|
#[serde(default = "default_max_tokens")]
|
|
pub max_tokens: u32,
|
|
|
|
/// Temperature
|
|
#[serde(default = "default_temperature")]
|
|
pub temperature: f32,
|
|
|
|
/// Context window size in tokens (default: 128000)
|
|
/// Used to calculate dynamic compaction threshold.
|
|
#[serde(default = "default_context_window")]
|
|
pub context_window: u32,
|
|
}
|
|
|
|
impl LlmConfig {
|
|
/// Create a new LLM config
|
|
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self {
|
|
base_url: base_url.into(),
|
|
api_key: api_key.into(),
|
|
model: model.into(),
|
|
api_protocol: ApiProtocol::OpenAI,
|
|
max_tokens: default_max_tokens(),
|
|
temperature: default_temperature(),
|
|
context_window: default_context_window(),
|
|
}
|
|
}
|
|
|
|
/// Set API protocol
|
|
pub fn with_protocol(mut self, protocol: ApiProtocol) -> Self {
|
|
self.api_protocol = protocol;
|
|
self
|
|
}
|
|
|
|
/// Set max tokens
|
|
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
|
|
self.max_tokens = max_tokens;
|
|
self
|
|
}
|
|
|
|
/// Set temperature
|
|
pub fn with_temperature(mut self, temperature: f32) -> Self {
|
|
self.temperature = temperature;
|
|
self
|
|
}
|
|
|
|
/// Create driver from this config
|
|
pub fn create_driver(&self) -> Result<Arc<dyn LlmDriver>> {
|
|
match self.api_protocol {
|
|
ApiProtocol::Anthropic => {
|
|
if self.base_url.is_empty() {
|
|
Ok(Arc::new(AnthropicDriver::new(SecretString::new(self.api_key.clone()))))
|
|
} else {
|
|
Ok(Arc::new(AnthropicDriver::with_base_url(
|
|
SecretString::new(self.api_key.clone()),
|
|
self.base_url.clone(),
|
|
)))
|
|
}
|
|
}
|
|
ApiProtocol::OpenAI => {
|
|
Ok(Arc::new(OpenAiDriver::with_base_url(
|
|
SecretString::new(self.api_key.clone()),
|
|
self.base_url.clone(),
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Kernel configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct KernelConfig {
|
|
/// Database URL (SQLite)
|
|
#[serde(default = "default_database_url")]
|
|
pub database_url: String,
|
|
|
|
/// LLM configuration
|
|
#[serde(flatten)]
|
|
pub llm: LlmConfig,
|
|
|
|
/// Skills directory path (optional, defaults to ./skills)
|
|
#[serde(default)]
|
|
pub skills_dir: Option<PathBuf>,
|
|
}
|
|
|
|
fn default_database_url() -> String {
|
|
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
|
let dir = home.join(".zclaw");
|
|
format!("sqlite:{}/data.db?mode=rwc", dir.display())
|
|
}
|
|
|
|
fn default_max_tokens() -> u32 {
|
|
4096
|
|
}
|
|
|
|
fn default_temperature() -> f32 {
|
|
0.7
|
|
}
|
|
|
|
fn default_context_window() -> u32 {
|
|
128000
|
|
}
|
|
|
|
impl Default for KernelConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
database_url: default_database_url(),
|
|
llm: LlmConfig {
|
|
base_url: "https://api.openai.com/v1".to_string(),
|
|
api_key: String::new(),
|
|
model: "gpt-4o-mini".to_string(),
|
|
api_protocol: ApiProtocol::OpenAI,
|
|
max_tokens: default_max_tokens(),
|
|
temperature: default_temperature(),
|
|
context_window: default_context_window(),
|
|
},
|
|
skills_dir: default_skills_dir(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Default skills directory
|
|
///
|
|
/// Discovery order:
|
|
/// 1. ZCLAW_SKILLS_DIR environment variable (if set)
|
|
/// 2. Compile-time known workspace path (CARGO_WORKSPACE_DIR or relative from manifest dir)
|
|
/// 3. Current working directory/skills (for development)
|
|
/// 4. Executable directory and multiple levels up (for packaged apps)
|
|
fn default_skills_dir() -> Option<std::path::PathBuf> {
|
|
// 1. Check environment variable override
|
|
if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") {
|
|
let path = std::path::PathBuf::from(&dir);
|
|
tracing::debug!(target: "kernel_config", "ZCLAW_SKILLS_DIR env: {} (exists: {})", path.display(), path.exists());
|
|
if path.exists() {
|
|
return Some(path);
|
|
}
|
|
// Even if it doesn't exist, respect the env var
|
|
return Some(path);
|
|
}
|
|
|
|
// 2. Try compile-time known paths (works for cargo build/test)
|
|
// CARGO_MANIFEST_DIR is the crate directory (crates/zclaw-kernel)
|
|
// We need to go up to find the workspace root
|
|
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
tracing::debug!(target: "kernel_config", "CARGO_MANIFEST_DIR: {}", manifest_dir.display());
|
|
|
|
// Go up from crates/zclaw-kernel to workspace root
|
|
if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) {
|
|
let workspace_skills = workspace_root.join("skills");
|
|
tracing::debug!(target: "kernel_config", "Workspace skills: {} (exists: {})", workspace_skills.display(), workspace_skills.exists());
|
|
if workspace_skills.exists() {
|
|
return Some(workspace_skills);
|
|
}
|
|
}
|
|
|
|
// 3. Try current working directory first (for development)
|
|
if let Ok(cwd) = std::env::current_dir() {
|
|
let cwd_skills = cwd.join("skills");
|
|
tracing::debug!(target: "kernel_config", "Checking cwd: {} (exists: {})", cwd_skills.display(), cwd_skills.exists());
|
|
if cwd_skills.exists() {
|
|
return Some(cwd_skills);
|
|
}
|
|
|
|
// Also try going up from cwd (might be in desktop/src-tauri)
|
|
let mut current = cwd.as_path();
|
|
for i in 0..6 {
|
|
if let Some(parent) = current.parent() {
|
|
let parent_skills = parent.join("skills");
|
|
tracing::debug!(target: "kernel_config", "CWD Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
|
if parent_skills.exists() {
|
|
return Some(parent_skills);
|
|
}
|
|
current = parent;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Try executable's directory and multiple levels up
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
tracing::debug!(target: "kernel_config", "Current exe: {}", exe.display());
|
|
if let Some(exe_dir) = exe.parent().map(|p| p.to_path_buf()) {
|
|
// Same directory as exe
|
|
let exe_skills = exe_dir.join("skills");
|
|
tracing::debug!(target: "kernel_config", "Checking exe dir: {} (exists: {})", exe_skills.display(), exe_skills.exists());
|
|
if exe_skills.exists() {
|
|
return Some(exe_skills);
|
|
}
|
|
|
|
// Go up multiple levels to handle Tauri dev builds
|
|
let mut current = exe_dir.as_path();
|
|
for i in 0..6 {
|
|
if let Some(parent) = current.parent() {
|
|
let parent_skills = parent.join("skills");
|
|
tracing::debug!(target: "kernel_config", "EXE Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists());
|
|
if parent_skills.exists() {
|
|
return Some(parent_skills);
|
|
}
|
|
current = parent;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Fallback to current working directory/skills (may not exist)
|
|
let fallback = std::env::current_dir()
|
|
.ok()
|
|
.map(|cwd| cwd.join("skills"));
|
|
tracing::debug!(target: "kernel_config", "Fallback to: {:?}", fallback);
|
|
fallback
|
|
}
|
|
|
|
impl KernelConfig {
|
|
/// Load configuration from file.
|
|
///
|
|
/// Search order:
|
|
/// 1. Path from `ZCLAW_CONFIG` environment variable
|
|
/// 2. `~/.zclaw/config.toml`
|
|
/// 3. Fallback to `Self::default()`
|
|
///
|
|
/// Supports `${VAR_NAME}` environment variable interpolation in string values.
|
|
pub async fn load() -> Result<Self> {
|
|
let config_path = Self::find_config_path();
|
|
|
|
match config_path {
|
|
Some(path) => {
|
|
if !path.exists() {
|
|
tracing::debug!(target: "kernel_config", "Config file not found: {:?}, using defaults", path);
|
|
return Ok(Self::default());
|
|
}
|
|
|
|
tracing::info!(target: "kernel_config", "Loading config from: {:?}", path);
|
|
let content = std::fs::read_to_string(&path).map_err(|e| {
|
|
zclaw_types::ZclawError::Internal(format!("Failed to read config {}: {}", path.display(), e))
|
|
})?;
|
|
|
|
let interpolated = interpolate_env_vars(&content);
|
|
let mut config: KernelConfig = toml::from_str(&interpolated).map_err(|e| {
|
|
zclaw_types::ZclawError::Internal(format!("Failed to parse config {}: {}", path.display(), e))
|
|
})?;
|
|
|
|
// Resolve skills_dir if not explicitly set
|
|
if config.skills_dir.is_none() {
|
|
config.skills_dir = default_skills_dir();
|
|
}
|
|
|
|
tracing::info!(
|
|
target: "kernel_config",
|
|
model = %config.llm.model,
|
|
base_url = %config.llm.base_url,
|
|
has_api_key = !config.llm.api_key.is_empty(),
|
|
"Config loaded successfully"
|
|
);
|
|
|
|
Ok(config)
|
|
}
|
|
None => Ok(Self::default()),
|
|
}
|
|
}
|
|
|
|
/// Find the config file path.
|
|
pub fn find_config_path() -> Option<PathBuf> {
|
|
// 1. Environment variable override
|
|
if let Ok(path) = std::env::var("ZCLAW_CONFIG") {
|
|
return Some(PathBuf::from(path));
|
|
}
|
|
|
|
// 2. ~/.zclaw/config.toml
|
|
if let Some(home) = dirs::home_dir() {
|
|
let path = home.join(".zclaw").join("config.toml");
|
|
if path.exists() {
|
|
return Some(path);
|
|
}
|
|
}
|
|
|
|
// 3. Project root config/config.toml (for development)
|
|
let project_config = std::env::current_dir()
|
|
.ok()
|
|
.map(|cwd| cwd.join("config").join("config.toml"))?;
|
|
|
|
if project_config.exists() {
|
|
return Some(project_config);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Create the LLM driver
|
|
pub fn create_driver(&self) -> Result<Arc<dyn LlmDriver>> {
|
|
self.llm.create_driver()
|
|
}
|
|
|
|
/// Get the model ID (passed directly to API)
|
|
pub fn model(&self) -> &str {
|
|
&self.llm.model
|
|
}
|
|
|
|
/// Get max tokens
|
|
pub fn max_tokens(&self) -> u32 {
|
|
self.llm.max_tokens
|
|
}
|
|
|
|
/// Get temperature
|
|
pub fn temperature(&self) -> f32 {
|
|
self.llm.temperature
|
|
}
|
|
|
|
/// Get context window size in tokens
|
|
pub fn context_window(&self) -> u32 {
|
|
self.llm.context_window
|
|
}
|
|
|
|
/// Dynamic compaction threshold = context_window * 0.6
|
|
/// Leaves 40% headroom for system prompt + response tokens
|
|
pub fn compaction_threshold(&self) -> usize {
|
|
(self.llm.context_window as f64 * 0.6) as usize
|
|
}
|
|
}
|
|
|
|
// === Preset configurations for common providers ===
|
|
|
|
impl LlmConfig {
|
|
/// OpenAI GPT-4
|
|
pub fn openai(api_key: impl Into<String>) -> Self {
|
|
Self::new("https://api.openai.com/v1", api_key, "gpt-4o")
|
|
}
|
|
|
|
/// Anthropic Claude
|
|
pub fn anthropic(api_key: impl Into<String>) -> Self {
|
|
Self::new("https://api.anthropic.com", api_key, "claude-sonnet-4-20250514")
|
|
.with_protocol(ApiProtocol::Anthropic)
|
|
}
|
|
|
|
/// 智谱 GLM
|
|
pub fn zhipu(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new("https://open.bigmodel.cn/api/paas/v4", api_key, model)
|
|
}
|
|
|
|
/// 智谱 GLM Coding Plan
|
|
pub fn zhipu_coding(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new("https://open.bigmodel.cn/api/coding/paas/v4", api_key, model)
|
|
}
|
|
|
|
/// Kimi (Moonshot)
|
|
pub fn kimi(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new("https://api.moonshot.cn/v1", api_key, model)
|
|
}
|
|
|
|
/// Kimi Coding Plan
|
|
pub fn kimi_coding(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new("https://api.kimi.com/coding/v1", api_key, model)
|
|
}
|
|
|
|
/// 阿里云百炼 (Qwen)
|
|
pub fn qwen(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new("https://dashscope.aliyuncs.com/compatible-mode/v1", api_key, model)
|
|
}
|
|
|
|
/// 阿里云百炼 Coding Plan
|
|
pub fn qwen_coding(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new("https://coding.dashscope.aliyuncs.com/v1", api_key, model)
|
|
}
|
|
|
|
/// DeepSeek
|
|
pub fn deepseek(api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new("https://api.deepseek.com/v1", api_key, model)
|
|
}
|
|
|
|
/// Ollama / Local
|
|
pub fn local(base_url: impl Into<String>, model: impl Into<String>) -> Self {
|
|
Self::new(base_url, "", model)
|
|
}
|
|
}
|
|
|
|
// === Backward compatibility ===
|
|
|
|
/// Provider type for backward compatibility
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Provider {
|
|
OpenAI,
|
|
Anthropic,
|
|
Gemini,
|
|
Zhipu,
|
|
Kimi,
|
|
Qwen,
|
|
DeepSeek,
|
|
Local,
|
|
Custom,
|
|
}
|
|
|
|
impl KernelConfig {
|
|
/// Create config from provider type (for backward compatibility with Tauri commands)
|
|
pub fn from_provider(
|
|
provider: &str,
|
|
api_key: &str,
|
|
model: &str,
|
|
base_url: Option<&str>,
|
|
api_protocol: &str,
|
|
) -> Self {
|
|
let llm = match provider {
|
|
"anthropic" => LlmConfig::anthropic(api_key).with_model(model),
|
|
"openai" => {
|
|
if let Some(url) = base_url.filter(|u| !u.is_empty()) {
|
|
LlmConfig::new(url, api_key, model)
|
|
} else {
|
|
LlmConfig::openai(api_key).with_model(model)
|
|
}
|
|
}
|
|
// P2-21: Gemini 暂停支持 — 前期不使用非国内大模型
|
|
// 保留代码,但前端已标记为暂停,不再可选
|
|
"gemini" => LlmConfig::new(
|
|
base_url.unwrap_or("https://generativelanguage.googleapis.com/v1beta"),
|
|
api_key,
|
|
model,
|
|
),
|
|
"zhipu" => {
|
|
let url = base_url.unwrap_or("https://open.bigmodel.cn/api/paas/v4");
|
|
LlmConfig::zhipu(api_key, model).with_base_url(url)
|
|
}
|
|
"zhipu-coding" => {
|
|
let url = base_url.unwrap_or("https://open.bigmodel.cn/api/coding/paas/v4");
|
|
LlmConfig::zhipu_coding(api_key, model).with_base_url(url)
|
|
}
|
|
"kimi" => {
|
|
let url = base_url.unwrap_or("https://api.moonshot.cn/v1");
|
|
LlmConfig::kimi(api_key, model).with_base_url(url)
|
|
}
|
|
"kimi-coding" => {
|
|
let url = base_url.unwrap_or("https://api.kimi.com/coding/v1");
|
|
LlmConfig::kimi_coding(api_key, model).with_base_url(url)
|
|
}
|
|
"qwen" => {
|
|
let url = base_url.unwrap_or("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
|
LlmConfig::qwen(api_key, model).with_base_url(url)
|
|
}
|
|
"qwen-coding" => {
|
|
let url = base_url.unwrap_or("https://coding.dashscope.aliyuncs.com/v1");
|
|
LlmConfig::qwen_coding(api_key, model).with_base_url(url)
|
|
}
|
|
"deepseek" => LlmConfig::deepseek(api_key, model),
|
|
"local" | "ollama" => {
|
|
let url = base_url.unwrap_or("http://localhost:11434/v1");
|
|
LlmConfig::local(url, model)
|
|
}
|
|
_ => {
|
|
// Custom provider
|
|
let protocol = if api_protocol == "anthropic" {
|
|
ApiProtocol::Anthropic
|
|
} else {
|
|
ApiProtocol::OpenAI
|
|
};
|
|
LlmConfig::new(
|
|
base_url.unwrap_or("https://api.openai.com/v1"),
|
|
api_key,
|
|
model,
|
|
)
|
|
.with_protocol(protocol)
|
|
}
|
|
};
|
|
|
|
Self {
|
|
database_url: default_database_url(),
|
|
llm,
|
|
skills_dir: default_skills_dir(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LlmConfig {
|
|
/// Set model
|
|
pub fn with_model(mut self, model: impl Into<String>) -> Self {
|
|
self.model = model.into();
|
|
self
|
|
}
|
|
|
|
/// Set base URL
|
|
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
|
self.base_url = base_url.into();
|
|
self
|
|
}
|
|
}
|
|
|
|
// === Environment variable interpolation ===
|
|
|
|
/// Replace `${VAR_NAME}` patterns in a string with environment variable values.
|
|
/// If the variable is not set, the pattern is left as-is.
|
|
fn interpolate_env_vars(content: &str) -> String {
|
|
let mut result = String::with_capacity(content.len());
|
|
let mut chars = content.char_indices().peekable();
|
|
|
|
while let Some((_, ch)) = chars.next() {
|
|
if ch == '$' && chars.peek().map(|(_, c)| *c == '{').unwrap_or(false) {
|
|
chars.next(); // consume '{'
|
|
|
|
let mut var_name = String::new();
|
|
|
|
while let Some((_, c)) = chars.peek() {
|
|
match c {
|
|
'}' => {
|
|
chars.next(); // consume '}'
|
|
if let Ok(value) = std::env::var(&var_name) {
|
|
result.push_str(&value);
|
|
} else {
|
|
result.push_str("${");
|
|
result.push_str(&var_name);
|
|
result.push('}');
|
|
}
|
|
break;
|
|
}
|
|
_ => {
|
|
var_name.push(*c);
|
|
chars.next();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unclosed ${... at end of string
|
|
if !content[result.len()..].contains('}') && var_name.is_empty() {
|
|
// Already consumed, nothing to do
|
|
}
|
|
} else {
|
|
result.push(ch);
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_interpolate_env_vars_basic() {
|
|
std::env::set_var("ZCLAW_TEST_VAR", "hello");
|
|
let result = interpolate_env_vars("prefix ${ZCLAW_TEST_VAR} suffix");
|
|
assert_eq!(result, "prefix hello suffix");
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_env_vars_missing() {
|
|
let result = interpolate_env_vars("${ZCLAW_NONEXISTENT_VAR_12345}");
|
|
assert_eq!(result, "${ZCLAW_NONEXISTENT_VAR_12345}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_env_vars_no_vars() {
|
|
let result = interpolate_env_vars("no variables here");
|
|
assert_eq!(result, "no variables here");
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_env_vars_multiple() {
|
|
std::env::set_var("ZCLAW_TEST_A", "alpha");
|
|
std::env::set_var("ZCLAW_TEST_B", "beta");
|
|
let result = interpolate_env_vars("${ZCLAW_TEST_A}-${ZCLAW_TEST_B}");
|
|
assert_eq!(result, "alpha-beta");
|
|
}
|
|
}
|