feat: initial release — ZeroClaw v0.1.0
- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.) - 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook) - 5-step onboarding wizard with Project Context personalization - OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.) - SQLite memory backend with auto-save - Skills system with on-demand loading - Security: autonomy levels, command allowlists, cost limits - 532 tests passing, 0 clippy warnings
This commit is contained in:
commit
05cb353f7f
71 changed files with 15757 additions and 0 deletions
580
src/config/schema.rs
Normal file
580
src/config/schema.rs
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
use crate::security::AutonomyLevel;
|
||||
use anyhow::{Context, Result};
|
||||
use directories::UserDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ── Top-level config ──────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub workspace_dir: PathBuf,
|
||||
pub config_path: PathBuf,
|
||||
pub api_key: Option<String>,
|
||||
pub default_provider: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_temperature: f64,
|
||||
|
||||
#[serde(default)]
|
||||
pub observability: ObservabilityConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub autonomy: AutonomyConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub runtime: RuntimeConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub heartbeat: HeartbeatConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub channels_config: ChannelsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub memory: MemoryConfig,
|
||||
}
|
||||
|
||||
// ── Memory ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryConfig {
|
||||
/// "sqlite" | "markdown" | "none"
|
||||
pub backend: String,
|
||||
/// Auto-save conversation context to memory
|
||||
pub auto_save: bool,
|
||||
}
|
||||
|
||||
impl Default for MemoryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: "sqlite".into(),
|
||||
auto_save: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Observability ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObservabilityConfig {
|
||||
/// "none" | "log" | "prometheus" | "otel"
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
impl Default for ObservabilityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: "none".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autonomy / Security ──────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutonomyConfig {
|
||||
pub level: AutonomyLevel,
|
||||
pub workspace_only: bool,
|
||||
pub allowed_commands: Vec<String>,
|
||||
pub forbidden_paths: Vec<String>,
|
||||
pub max_actions_per_hour: u32,
|
||||
pub max_cost_per_day_cents: u32,
|
||||
}
|
||||
|
||||
impl Default for AutonomyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: AutonomyLevel::Supervised,
|
||||
workspace_only: true,
|
||||
allowed_commands: vec![
|
||||
"git".into(),
|
||||
"npm".into(),
|
||||
"cargo".into(),
|
||||
"ls".into(),
|
||||
"cat".into(),
|
||||
"grep".into(),
|
||||
"find".into(),
|
||||
"echo".into(),
|
||||
"pwd".into(),
|
||||
"wc".into(),
|
||||
"head".into(),
|
||||
"tail".into(),
|
||||
],
|
||||
forbidden_paths: vec![
|
||||
"/etc".into(),
|
||||
"/root".into(),
|
||||
"~/.ssh".into(),
|
||||
"~/.gnupg".into(),
|
||||
],
|
||||
max_actions_per_hour: 20,
|
||||
max_cost_per_day_cents: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Runtime ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeConfig {
|
||||
/// "native" | "docker" | "cloudflare"
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
impl Default for RuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: "native".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeartbeatConfig {
|
||||
pub enabled: bool,
|
||||
pub interval_minutes: u32,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
interval_minutes: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelsConfig {
|
||||
pub cli: bool,
|
||||
pub telegram: Option<TelegramConfig>,
|
||||
pub discord: Option<DiscordConfig>,
|
||||
pub slack: Option<SlackConfig>,
|
||||
pub webhook: Option<WebhookConfig>,
|
||||
pub imessage: Option<IMessageConfig>,
|
||||
pub matrix: Option<MatrixConfig>,
|
||||
}
|
||||
|
||||
impl Default for ChannelsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cli: true,
|
||||
telegram: None,
|
||||
discord: None,
|
||||
slack: None,
|
||||
webhook: None,
|
||||
imessage: None,
|
||||
matrix: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TelegramConfig {
|
||||
pub bot_token: String,
|
||||
pub allowed_users: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscordConfig {
|
||||
pub bot_token: String,
|
||||
pub guild_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlackConfig {
|
||||
pub bot_token: String,
|
||||
pub app_token: Option<String>,
|
||||
pub channel_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookConfig {
|
||||
pub port: u16,
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IMessageConfig {
|
||||
pub allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MatrixConfig {
|
||||
pub homeserver: String,
|
||||
pub access_token: String,
|
||||
pub room_id: String,
|
||||
pub allowed_users: Vec<String>,
|
||||
}
|
||||
|
||||
// ── Config impl ──────────────────────────────────────────────────
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let home =
|
||||
UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
|
||||
let zeroclaw_dir = home.join(".zeroclaw");
|
||||
|
||||
Self {
|
||||
workspace_dir: zeroclaw_dir.join("workspace"),
|
||||
config_path: zeroclaw_dir.join("config.toml"),
|
||||
api_key: None,
|
||||
default_provider: Some("openrouter".to_string()),
|
||||
default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
|
||||
default_temperature: 0.7,
|
||||
observability: ObservabilityConfig::default(),
|
||||
autonomy: AutonomyConfig::default(),
|
||||
runtime: RuntimeConfig::default(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
channels_config: ChannelsConfig::default(),
|
||||
memory: MemoryConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load_or_init() -> Result<Self> {
|
||||
let home = UserDirs::new()
|
||||
.map(|u| u.home_dir().to_path_buf())
|
||||
.context("Could not find home directory")?;
|
||||
let zeroclaw_dir = home.join(".zeroclaw");
|
||||
let config_path = zeroclaw_dir.join("config.toml");
|
||||
|
||||
if !zeroclaw_dir.exists() {
|
||||
fs::create_dir_all(&zeroclaw_dir).context("Failed to create .zeroclaw directory")?;
|
||||
fs::create_dir_all(zeroclaw_dir.join("workspace"))
|
||||
.context("Failed to create workspace directory")?;
|
||||
}
|
||||
|
||||
if config_path.exists() {
|
||||
let contents =
|
||||
fs::read_to_string(&config_path).context("Failed to read config file")?;
|
||||
let config: Config =
|
||||
toml::from_str(&contents).context("Failed to parse config file")?;
|
||||
Ok(config)
|
||||
} else {
|
||||
let config = Config::default();
|
||||
config.save()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let toml_str = toml::to_string_pretty(self).context("Failed to serialize config")?;
|
||||
fs::write(&self.config_path, toml_str).context("Failed to write config file")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ── Defaults ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_default_has_sane_values() {
|
||||
let c = Config::default();
|
||||
assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
|
||||
assert!(c.default_model.as_deref().unwrap().contains("claude"));
|
||||
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
|
||||
assert!(c.api_key.is_none());
|
||||
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
|
||||
assert!(c.config_path.to_string_lossy().contains("config.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observability_config_default() {
|
||||
let o = ObservabilityConfig::default();
|
||||
assert_eq!(o.backend, "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomy_config_default() {
|
||||
let a = AutonomyConfig::default();
|
||||
assert_eq!(a.level, AutonomyLevel::Supervised);
|
||||
assert!(a.workspace_only);
|
||||
assert!(a.allowed_commands.contains(&"git".to_string()));
|
||||
assert!(a.allowed_commands.contains(&"cargo".to_string()));
|
||||
assert!(a.forbidden_paths.contains(&"/etc".to_string()));
|
||||
assert_eq!(a.max_actions_per_hour, 20);
|
||||
assert_eq!(a.max_cost_per_day_cents, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_config_default() {
|
||||
let r = RuntimeConfig::default();
|
||||
assert_eq!(r.kind, "native");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_config_default() {
|
||||
let h = HeartbeatConfig::default();
|
||||
assert!(!h.enabled);
|
||||
assert_eq!(h.interval_minutes, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_config_default() {
|
||||
let c = ChannelsConfig::default();
|
||||
assert!(c.cli);
|
||||
assert!(c.telegram.is_none());
|
||||
assert!(c.discord.is_none());
|
||||
}
|
||||
|
||||
// ── Serde round-trip ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn config_toml_roundtrip() {
|
||||
let config = Config {
|
||||
workspace_dir: PathBuf::from("/tmp/test/workspace"),
|
||||
config_path: PathBuf::from("/tmp/test/config.toml"),
|
||||
api_key: Some("sk-test-key".into()),
|
||||
default_provider: Some("openrouter".into()),
|
||||
default_model: Some("gpt-4o".into()),
|
||||
default_temperature: 0.5,
|
||||
observability: ObservabilityConfig {
|
||||
backend: "log".into(),
|
||||
},
|
||||
autonomy: AutonomyConfig {
|
||||
level: AutonomyLevel::Full,
|
||||
workspace_only: false,
|
||||
allowed_commands: vec!["docker".into()],
|
||||
forbidden_paths: vec!["/secret".into()],
|
||||
max_actions_per_hour: 50,
|
||||
max_cost_per_day_cents: 1000,
|
||||
},
|
||||
runtime: RuntimeConfig {
|
||||
kind: "docker".into(),
|
||||
},
|
||||
heartbeat: HeartbeatConfig {
|
||||
enabled: true,
|
||||
interval_minutes: 15,
|
||||
},
|
||||
channels_config: ChannelsConfig {
|
||||
cli: true,
|
||||
telegram: Some(TelegramConfig {
|
||||
bot_token: "123:ABC".into(),
|
||||
allowed_users: vec!["user1".into()],
|
||||
}),
|
||||
discord: None,
|
||||
slack: None,
|
||||
webhook: None,
|
||||
imessage: None,
|
||||
matrix: None,
|
||||
},
|
||||
memory: MemoryConfig::default(),
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
let parsed: Config = toml::from_str(&toml_str).unwrap();
|
||||
|
||||
assert_eq!(parsed.api_key, config.api_key);
|
||||
assert_eq!(parsed.default_provider, config.default_provider);
|
||||
assert_eq!(parsed.default_model, config.default_model);
|
||||
assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
|
||||
assert_eq!(parsed.observability.backend, "log");
|
||||
assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
|
||||
assert!(!parsed.autonomy.workspace_only);
|
||||
assert_eq!(parsed.runtime.kind, "docker");
|
||||
assert!(parsed.heartbeat.enabled);
|
||||
assert_eq!(parsed.heartbeat.interval_minutes, 15);
|
||||
assert!(parsed.channels_config.telegram.is_some());
|
||||
assert_eq!(
|
||||
parsed.channels_config.telegram.unwrap().bot_token,
|
||||
"123:ABC"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_minimal_toml_uses_defaults() {
|
||||
let minimal = r#"
|
||||
workspace_dir = "/tmp/ws"
|
||||
config_path = "/tmp/config.toml"
|
||||
default_temperature = 0.7
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(minimal).unwrap();
|
||||
assert!(parsed.api_key.is_none());
|
||||
assert!(parsed.default_provider.is_none());
|
||||
assert_eq!(parsed.observability.backend, "none");
|
||||
assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
|
||||
assert_eq!(parsed.runtime.kind, "native");
|
||||
assert!(!parsed.heartbeat.enabled);
|
||||
assert!(parsed.channels_config.cli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_save_and_load_tmpdir() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_config");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let config_path = dir.join("config.toml");
|
||||
let config = Config {
|
||||
workspace_dir: dir.join("workspace"),
|
||||
config_path: config_path.clone(),
|
||||
api_key: Some("sk-roundtrip".into()),
|
||||
default_provider: Some("openrouter".into()),
|
||||
default_model: Some("test-model".into()),
|
||||
default_temperature: 0.9,
|
||||
observability: ObservabilityConfig::default(),
|
||||
autonomy: AutonomyConfig::default(),
|
||||
runtime: RuntimeConfig::default(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
channels_config: ChannelsConfig::default(),
|
||||
memory: MemoryConfig::default(),
|
||||
};
|
||||
|
||||
config.save().unwrap();
|
||||
assert!(config_path.exists());
|
||||
|
||||
let contents = fs::read_to_string(&config_path).unwrap();
|
||||
let loaded: Config = toml::from_str(&contents).unwrap();
|
||||
assert_eq!(loaded.api_key.as_deref(), Some("sk-roundtrip"));
|
||||
assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
|
||||
assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ── Telegram / Discord config ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn telegram_config_serde() {
|
||||
let tc = TelegramConfig {
|
||||
bot_token: "123:XYZ".into(),
|
||||
allowed_users: vec!["alice".into(), "bob".into()],
|
||||
};
|
||||
let json = serde_json::to_string(&tc).unwrap();
|
||||
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.bot_token, "123:XYZ");
|
||||
assert_eq!(parsed.allowed_users.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_config_serde() {
|
||||
let dc = DiscordConfig {
|
||||
bot_token: "discord-token".into(),
|
||||
guild_id: Some("12345".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&dc).unwrap();
|
||||
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.bot_token, "discord-token");
|
||||
assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_config_optional_guild() {
|
||||
let dc = DiscordConfig {
|
||||
bot_token: "tok".into(),
|
||||
guild_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&dc).unwrap();
|
||||
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.guild_id.is_none());
|
||||
}
|
||||
|
||||
// ── iMessage / Matrix config ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn imessage_config_serde() {
|
||||
let ic = IMessageConfig {
|
||||
allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
|
||||
};
|
||||
let json = serde_json::to_string(&ic).unwrap();
|
||||
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.allowed_contacts.len(), 2);
|
||||
assert_eq!(parsed.allowed_contacts[0], "+1234567890");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imessage_config_empty_contacts() {
|
||||
let ic = IMessageConfig {
|
||||
allowed_contacts: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&ic).unwrap();
|
||||
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.allowed_contacts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imessage_config_wildcard() {
|
||||
let ic = IMessageConfig {
|
||||
allowed_contacts: vec!["*".into()],
|
||||
};
|
||||
let toml_str = toml::to_string(&ic).unwrap();
|
||||
let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(parsed.allowed_contacts, vec!["*"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_config_serde() {
|
||||
let mc = MatrixConfig {
|
||||
homeserver: "https://matrix.org".into(),
|
||||
access_token: "syt_token_abc".into(),
|
||||
room_id: "!room123:matrix.org".into(),
|
||||
allowed_users: vec!["@user:matrix.org".into()],
|
||||
};
|
||||
let json = serde_json::to_string(&mc).unwrap();
|
||||
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.homeserver, "https://matrix.org");
|
||||
assert_eq!(parsed.access_token, "syt_token_abc");
|
||||
assert_eq!(parsed.room_id, "!room123:matrix.org");
|
||||
assert_eq!(parsed.allowed_users.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_config_toml_roundtrip() {
|
||||
let mc = MatrixConfig {
|
||||
homeserver: "https://synapse.local:8448".into(),
|
||||
access_token: "tok".into(),
|
||||
room_id: "!abc:synapse.local".into(),
|
||||
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
|
||||
};
|
||||
let toml_str = toml::to_string(&mc).unwrap();
|
||||
let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(parsed.homeserver, "https://synapse.local:8448");
|
||||
assert_eq!(parsed.allowed_users.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_config_with_imessage_and_matrix() {
|
||||
let c = ChannelsConfig {
|
||||
cli: true,
|
||||
telegram: None,
|
||||
discord: None,
|
||||
slack: None,
|
||||
webhook: None,
|
||||
imessage: Some(IMessageConfig {
|
||||
allowed_contacts: vec!["+1".into()],
|
||||
}),
|
||||
matrix: Some(MatrixConfig {
|
||||
homeserver: "https://m.org".into(),
|
||||
access_token: "tok".into(),
|
||||
room_id: "!r:m".into(),
|
||||
allowed_users: vec!["@u:m".into()],
|
||||
}),
|
||||
};
|
||||
let toml_str = toml::to_string_pretty(&c).unwrap();
|
||||
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert!(parsed.imessage.is_some());
|
||||
assert!(parsed.matrix.is_some());
|
||||
assert_eq!(
|
||||
parsed.imessage.unwrap().allowed_contacts,
|
||||
vec!["+1"]
|
||||
);
|
||||
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_config_default_has_no_imessage_matrix() {
|
||||
let c = ChannelsConfig::default();
|
||||
assert!(c.imessage.is_none());
|
||||
assert!(c.matrix.is_none());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue