refactor: massive codebase restructuring — naming, splitting, DRY
Crate renames: - zesdex-entities::seaorm → domain (misleading name, no SeaORM used) - zesdex-dto → merged into zesdex-entities (100% re-exports) - zesdex-libs → zesdex-infra (vague name) Module renames: - app/harness → guard (misleading: safety gatekeeper, not test harness) - runtime/commands → action_dispatch (name clashed with controller/command) - resources → prompts (embedded prompt text, not general resources) - tool/seqthink → sequential_think (unreadable abbreviation) - msglog/query → insert (module only inserts, never queries) Dead code removal: - app/mode/help.rs (orphaned — not declared in mod.rs) - app/mode/loading.rs (orphaned — not declared in mod.rs) File splitting (71 new files, avg ~115 lines/file): - app/runtime/actions/: 1→8 files (was 2030 lines) - view/overlays/: 1→16 files (was 1167 lines) - tool/lsp/: 1→8 per-tool files (was 909 lines) - main.rs: 1→5 files (session, daemon, attach, event_loop) - workflow/engine + hive_mind: 2→10 files - subagent/engine + auto: 2→9 files - lsp/provisioner: 1→5 files - review/: 1→6 files - guard/: 1→2 files (extracted patterns) - state/misc: 1→3 files (input, scroll) - mcp/: 1→3 files (transport, adapter) - stream/json_repair extracted from turn.rs DRY: - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent - 3 near-identical background spawners → 1 generic + thin wrappers - Shared spawn_subagent_with_drain() extracted - Shared create_session() in main - write_osc52 deduplicated Bug fixes: - archive_message(): sess.db → db (wrong variable name) - execute_one_tool(): wrong parameter name - check_credential_read() function was missing (restored from test expectations)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
//! Authentication entities: session metadata and PID-file lock.
|
||||
|
||||
pub mod session;
|
||||
pub mod session_lock;
|
||||
|
||||
pub use session::Session;
|
||||
pub use session_lock::SessionLock;
|
||||
@@ -0,0 +1,128 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||
//! persisted as `session.json` per session directory.
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata for one conversation session (distinct from the message
|
||||
/// history itself, which lives in `Conversation`/the msglog).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub title: String,
|
||||
pub model: String,
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
pub message_count: u32,
|
||||
pub token_count: u32,
|
||||
pub archived: bool,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session with the given id/title, defaulting the
|
||||
/// model, workspace root (current dir), and counters.
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Session {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
/// Compute this session's `conversation.json` path.
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
|
||||
/// Persist this session's metadata to `session.json`, atomically
|
||||
/// with fsync for crash safety.
|
||||
///
|
||||
/// Flow: ensure the session directory exists → serialize to pretty
|
||||
/// JSON → write to `session.json.tmp` → fsync → rename over
|
||||
/// `session.json` → fsync parent directory.
|
||||
///
|
||||
/// Why: write-then-rename avoids a torn/partial `session.json` if
|
||||
/// interrupted mid-write; fsync before rename ensures the data is
|
||||
/// on disk before the rename makes it visible.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` from any step.
|
||||
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
|
||||
let dir = self.session_dir(base_dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("session.json");
|
||||
let data = serde_json::to_string_pretty(self)?;
|
||||
let tmp = dir.join("session.json.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a session's metadata by id from `<base_dir>/sessions/<id>/session.json`.
|
||||
///
|
||||
/// Security: the session id is validated to prevent directory traversal
|
||||
/// (e.g. `../../etc/passwd`). Only alphanumeric, hyphens, underscores,
|
||||
/// and dots are allowed — no path separators.
|
||||
///
|
||||
/// Return: the parsed `Session`, or an `io::Error` if the file is
|
||||
/// missing or malformed.
|
||||
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
|
||||
// Reject session ids that contain path separators or parent dir refs
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid session id '{id}': must not contain path separators"),
|
||||
));
|
||||
}
|
||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let session: Session = serde_json::from_str(&data)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// List all loadable sessions under `<base_dir>/sessions/`.
|
||||
///
|
||||
/// Flow: read the sessions directory → keep subdirectories → attempt
|
||||
/// `Session::load` for each by its directory name, discarding any
|
||||
/// that fail to load.
|
||||
///
|
||||
/// Return: a `Vec<Session>`, empty if the directory can't be read or
|
||||
/// contains no valid sessions.
|
||||
pub fn list(base_dir: &Path) -> Vec<Self> {
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let Ok(entries) = std::fs::read_dir(&sessions_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| {
|
||||
let id = e.file_name().to_string_lossy().to_string();
|
||||
Session::load(&id, base_dir).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! the same session directory concurrently.
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||
/// auto-removed on drop.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
/// Construct a lock handle for a session directory (does not acquire
|
||||
/// the lock yet — call `try_lock`).
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
pid: std::process::id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to acquire the session lock using an atomic file creation.
|
||||
///
|
||||
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
||||
/// succeeds, the lock is ours — write our PID and return ok. If the
|
||||
/// file already exists, read the PID inside it and check `is_alive`:
|
||||
/// if that process is still running, fail to acquire; otherwise the
|
||||
/// lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Why: `create_new(true)` is atomic on POSIX (unlike the previous
|
||||
/// read-then-write pattern which had a TOCTOU race between checking
|
||||
/// `path.exists()` and writing). The stale-lock recovery path reads
|
||||
/// the stale PID and verifies liveness via `kill(pid, 0)`.
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
write!(file, "{}", self.pid)?;
|
||||
file.sync_all()?;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
// Lock file exists — check if it's stale.
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Phase 2: lock file exists — check liveness of the owning process.
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if Self::is_alive(pid) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: stale lock — overwrite it atomically (best-effort).
|
||||
// Use a temp file + rename to avoid partial writes corrupting the lock.
|
||||
let tmp = self.path.with_extension("lock.tmp");
|
||||
{
|
||||
let mut tmp_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
write!(tmp_file, "{}", self.pid)?;
|
||||
tmp_file.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, &self.path)?;
|
||||
// Sync the parent directory so the rename survives a crash.
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Explicitly release the lock by removing the lock file.
|
||||
pub fn unlock(&self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive and
|
||||
/// is actually a zesdex process (not a recycled PID from a different
|
||||
/// program).
|
||||
fn is_alive(pid: u32) -> bool {
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal
|
||||
// it. The integer argument is a PID already validated by `try_lock`.
|
||||
if unsafe { libc::kill(pid as i32, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
// Extra check: verify the PID belongs to a zesdex process via
|
||||
// /proc/<pid>/exe to mitigate the PID-reuse race (a recycled PID
|
||||
// from a different program would answer kill but shouldn't hold
|
||||
// our lock). This is best-effort — /proc may not be available
|
||||
// on all platforms.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionLock {
|
||||
/// Release the lock automatically when the guard goes out of scope,
|
||||
/// so an ungracefully-exited process doesn't leave a dangling lock.
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! In-memory conversation state: message history plus the system prompt and
|
||||
//! model parameters used to drive the LLM.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::message::{ChatMessage, Role};
|
||||
|
||||
/// A single conversation's message history and generation settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub system_prompt: String,
|
||||
pub session_id: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
/// Create an empty conversation with the given system prompt and
|
||||
/// session id, using default model/token/temperature settings.
|
||||
pub fn new(system_prompt: String, session_id: String) -> Self {
|
||||
Conversation {
|
||||
messages: Vec::new(),
|
||||
system_prompt,
|
||||
session_id,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the conversation history.
|
||||
pub fn push(&mut self, msg: ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
/// Replace the system prompt and strip any prior `System`-role
|
||||
/// messages from history.
|
||||
///
|
||||
/// Why: the system prompt is re-injected fresh at request time via
|
||||
/// `to_api_messages`, so stale `System` messages in `self.messages`
|
||||
/// would be redundant/conflicting if left in place.
|
||||
pub fn rebuild_system(&mut self, new_prompt: String) {
|
||||
self.system_prompt = new_prompt;
|
||||
self.messages.retain(|m| !matches!(m.role, Role::System));
|
||||
}
|
||||
|
||||
/// Build the message list to send to the LLM API, with the system
|
||||
/// prompt prepended.
|
||||
///
|
||||
/// Return: a new `Vec` (clone of history) with a synthesized system
|
||||
/// message at index 0.
|
||||
pub fn to_api_messages(&self) -> Vec<ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(ChatMessage::system(&self.system_prompt));
|
||||
msgs.extend(self.messages.iter().cloned());
|
||||
msgs
|
||||
}
|
||||
|
||||
/// Number of messages in the conversation history (excluding the
|
||||
/// synthesized system message).
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the conversation has no messages.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// Persist the conversation to a JSON file at the given base directory.
|
||||
///
|
||||
/// Flow: compute path from `session_id` → ensure directory exists →
|
||||
/// serialize to pretty JSON → write-then-rename with fsync.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` from any step.
|
||||
pub fn save_conversation(&self, base_dir: &std::path::Path) -> std::io::Result<()> {
|
||||
let dir = base_dir.join("sessions").join(&self.session_id);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("conversation.json");
|
||||
let data = serde_json::to_string_pretty(self)?;
|
||||
let tmp = dir.join("conversation.json.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a conversation from a JSON file for the given session id.
|
||||
///
|
||||
/// Flow: read `<base_dir>/sessions/<session_id>/conversation.json` →
|
||||
/// JSON-parse.
|
||||
///
|
||||
/// Return: the parsed `Conversation`, or an `io::Error` if the file is
|
||||
/// missing or malformed.
|
||||
pub fn load_conversation(
|
||||
session_id: &str,
|
||||
base_dir: &std::path::Path,
|
||||
) -> std::io::Result<Self> {
|
||||
let path = base_dir
|
||||
.join("sessions")
|
||||
.join(session_id)
|
||||
.join("conversation.json");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let conv: Conversation = serde_json::from_str(&data)?;
|
||||
Ok(conv)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Chat message types shared across the entity layer: `Role` and `ChatMessage`
|
||||
//! with convenience constructors.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The conversation participant who authored a message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant,
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
#[serde(rename = "tool")]
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Return the role as a lowercase string.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message in a conversation, compatible with the OpenAI/Anthropic
|
||||
/// chat-completion API structures.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Build a user-role message with the given text content.
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::User,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an assistant-role message with an optional text response.
|
||||
pub fn assistant(content: Option<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Assistant,
|
||||
content,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a system-role message with the given instruction text.
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::System,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a tool-role result message referencing a prior tool call.
|
||||
pub fn tool(tool_call_id: String, content: String) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Tool,
|
||||
content: Some(content),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias for `tool`, used throughout the codebase for tool results.
|
||||
pub fn tool_result(tool_call_id: String, content: String) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Tool,
|
||||
content: Some(content),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Common entity types shared across the Zesdex application: application
|
||||
//! configuration, settings, store paths, conversations, messages, tool
|
||||
//! calls, usage stats, and SSE streaming types.
|
||||
|
||||
pub mod conversation;
|
||||
pub mod message;
|
||||
pub mod provider;
|
||||
pub mod store;
|
||||
pub mod tool_call;
|
||||
pub mod tool_result;
|
||||
pub mod usage;
|
||||
|
||||
pub use conversation::Conversation;
|
||||
pub use message::{ChatMessage, Role};
|
||||
pub use provider::{
|
||||
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||
ToolFunctionDef,
|
||||
};
|
||||
pub use store::Store;
|
||||
pub use tool_call::{ToolCall, ToolFunction};
|
||||
pub use tool_result::ToolCallResult;
|
||||
pub use usage::UsageStats;
|
||||
@@ -0,0 +1,329 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Provider-facing DTOs: chat completion request, response, streaming types,
|
||||
//! and the SSE stream parser.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat request / response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
|
||||
/// provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<super::message::ChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDef>>,
|
||||
/// Controls which (if any) function is called by the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
}
|
||||
|
||||
/// Streaming options for the request; `include_usage` asks the provider to
|
||||
/// emit a final usage chunk in the SSE stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamOptions {
|
||||
pub include_usage: bool,
|
||||
}
|
||||
|
||||
/// Wire format for a single tool definition sent to the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDef {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub function: ToolFunctionDef,
|
||||
}
|
||||
|
||||
/// Name, description, and JSON schema parameters for a tool definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionDef {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Non-streaming chat completion response returned by the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
pub id: String,
|
||||
pub object: Option<String>,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
pub usage: Option<TokenUsage>,
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
/// One completion candidate within a `ChatResponse.choices` list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
pub index: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<super::message::ChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta: Option<Delta>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Incremental delta emitted in a streaming SSE chunk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Delta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<super::message::Role>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token counts and optional cost breakdown for a single completion request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_cost: Option<f64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE streaming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One atomic event extracted from an LLM streaming response stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StreamEvent {
|
||||
Token(String),
|
||||
Reasoning(String),
|
||||
ToolCallDelta {
|
||||
index: usize,
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments_delta: String,
|
||||
},
|
||||
Usage {
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
},
|
||||
Done,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Buffered SSE frame parser that accumulates raw `data:` lines and
|
||||
/// flushes a `StreamEvent` on each blank-line boundary.
|
||||
pub struct SseParser {
|
||||
buffer: String,
|
||||
event_type: Option<String>,
|
||||
data_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
/// Create a new parser with an empty buffer.
|
||||
pub fn new() -> Self {
|
||||
SseParser {
|
||||
buffer: String::new(),
|
||||
event_type: None,
|
||||
data_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a raw SSE chunk and produce any completed events.
|
||||
///
|
||||
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
|
||||
/// blank line, call `flush_event` to parse the accumulated data →
|
||||
/// on `event:` line, store the event type → on `data:` line, append
|
||||
/// to data accumulator → continue until buffer exhausted.
|
||||
///
|
||||
/// Edge case: a chunk may split mid-line; the remainder stays in the
|
||||
/// buffer for the next `feed()` call.
|
||||
///
|
||||
/// Return: all `StreamEvent`s completed by this chunk.
|
||||
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
|
||||
self.buffer.push_str(chunk);
|
||||
let mut events = Vec::new();
|
||||
while let Some(line_end) = self.buffer.find('\n') {
|
||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
||||
if line.is_empty() {
|
||||
events.extend(self.flush_event());
|
||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
||||
self.event_type = Some(ty.trim().to_string());
|
||||
} else if let Some(data) = line.strip_prefix("data:") {
|
||||
let data = data.trim_start().to_string();
|
||||
self.data_lines.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
|
||||
///
|
||||
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
|
||||
/// emit `Usage` if a usage object is present → else match `event_type`
|
||||
/// ("message.stop", "message.delta", etc.) → extract content,
|
||||
/// reasoning, tool-call deltas, or finish-reason from the delta
|
||||
/// structure (supporting both Anthropic-style top-level delta and
|
||||
/// OpenAI-style `choices` array).
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
let event_type = self.event_type.take().unwrap_or_default();
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
if data == "[DONE]" {
|
||||
return vec![StreamEvent::Done];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[stream] failed to parse chunk: {}", e);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
let prompt_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let completion_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||
prompt_tokens + completion_tokens
|
||||
});
|
||||
events.push(StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut other_events = match event_type.as_str() {
|
||||
"message.stop" => vec![StreamEvent::Done],
|
||||
"message.delta" | "" => {
|
||||
let mut d_events = Vec::new();
|
||||
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
|
||||
if let Some(choices) = delta.as_array() {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(d) = choice.get("delta") {
|
||||
// Content token
|
||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
|
||||
// Reasoning token
|
||||
if let Some(reasoning) =
|
||||
d.get("reasoning_content").and_then(|r| r.as_str())
|
||||
{
|
||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
|
||||
// Tool calls — iterate ALL entries, not just first()
|
||||
if let Some(tool_calls) =
|
||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||
{
|
||||
for tc in tool_calls {
|
||||
let index =
|
||||
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|
||||
|| {
|
||||
tracing::warn!(
|
||||
"[stream] tool call delta missing index, \
|
||||
defaulting to 0"
|
||||
);
|
||||
0
|
||||
},
|
||||
) as usize;
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let args_delta = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
d_events.push(StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta: args_delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if let Some(reason) =
|
||||
choice.get("finish_reason").and_then(|r| r.as_str())
|
||||
{
|
||||
if reason == "stop" || reason == "tool_calls" {
|
||||
d_events.push(StreamEvent::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
}
|
||||
d_events
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
events.append(&mut other_events);
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SseParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolved paths for all data directories zesdex reads from and writes to.
|
||||
///
|
||||
/// Why: centralizing path computation here means every consumer agrees on
|
||||
/// where memory, scratch, session images, and downloads live.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
pub base_dir: PathBuf,
|
||||
pub scratch_root: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub session_images_dir: PathBuf,
|
||||
pub download_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Compute the standard set of zesdex data directory paths.
|
||||
///
|
||||
/// Flow: OS data dir (or `.local/share` fallback) + "zesdex" → base dir;
|
||||
/// scratch root comes from the OS temp dir instead, since it's disposable.
|
||||
///
|
||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||
pub fn new() -> Self {
|
||||
let base = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
||||
.join("zesdex");
|
||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||
Store {
|
||||
memory_dir: base.join("memory"),
|
||||
scratch_root: scratch,
|
||||
session_images_dir: base.join("session-images"),
|
||||
download_dir: base.join("downloads"),
|
||||
base_dir: base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create all store directories (base, memory, scratch, session images,
|
||||
/// downloads) if missing.
|
||||
///
|
||||
/// Return: `Err` on the first directory that fails to create.
|
||||
pub fn ensure_dirs(&self) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&self.base_dir)?;
|
||||
std::fs::create_dir_all(&self.memory_dir)?;
|
||||
std::fs::create_dir_all(&self.scratch_root)?;
|
||||
std::fs::create_dir_all(&self.session_images_dir)?;
|
||||
std::fs::create_dir_all(&self.download_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Store {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Tool-call DTOs embedded in assistant chat messages.
|
||||
//!
|
||||
//! Flow: provider response/stream carries `tool_calls` on an assistant
|
||||
//! message → deserialized into `ToolCall`/`ToolFunction` → harness resolves
|
||||
//! `function.name` against `all_tools()` and runs it with
|
||||
//! `sanitize_tool_arguments(function.arguments)`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// A single tool-call request emitted by the model in an assistant message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
/// The function name and raw arguments payload for a `ToolCall`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Normalize tool-call arguments into a JSON object/value.
|
||||
///
|
||||
/// Flow: some providers send `arguments` as a JSON-encoded string rather
|
||||
/// than a nested object; if `args` is a string, attempt to parse it as
|
||||
/// JSON. Objects and other value types pass through unchanged.
|
||||
///
|
||||
/// Security: on parse failure we wrap the raw string in `{ "_raw": "..." }`
|
||||
/// instead of passing it through as a raw string, so tools that expect a
|
||||
/// JSON object (via `args.get("key")`) get `None` rather than unexpectedly
|
||||
/// receiving a plain string value.
|
||||
///
|
||||
/// Attempt to fix truncated JSON by closing open strings, braces and brackets.
|
||||
pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
||||
match args {
|
||||
Value::String(s) => {
|
||||
// Attempt 1: direct parse.
|
||||
if let Ok(v) = serde_json::from_str::<Value>(s) {
|
||||
return v;
|
||||
}
|
||||
// Attempt 2: strip control chars (0x00-0x1F except \t, \n)
|
||||
let cleaned: String = s
|
||||
.chars()
|
||||
.filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r')
|
||||
.collect();
|
||||
if cleaned.len() != s.len() {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
|
||||
tracing::warn!(
|
||||
"tool argument contained control characters — stripped \
|
||||
and reparsed successfully",
|
||||
);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// Attempt 3: repair truncated JSON and retry.
|
||||
let input = if cleaned.len() == s.len() {
|
||||
s
|
||||
} else {
|
||||
&cleaned
|
||||
};
|
||||
let repaired = repair_json(input);
|
||||
match serde_json::from_str::<Value>(&repaired) {
|
||||
Ok(v) => {
|
||||
tracing::warn!("tool argument string was truncated — repaired successfully",);
|
||||
v
|
||||
}
|
||||
Err(e2) => {
|
||||
tracing::error!(
|
||||
"tool argument is a JSON string but failed to parse. \
|
||||
Wrapping in object. Error: {}. Raw (first 200): {}",
|
||||
e2,
|
||||
s.chars().take(200).collect::<String>(),
|
||||
);
|
||||
serde_json::json!({"_raw": s, "_parse_error": e2.to_string()})
|
||||
}
|
||||
}
|
||||
}
|
||||
obj @ Value::Object(_) => obj.clone(),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair truncated JSON by closing open strings, braces and brackets.
|
||||
///
|
||||
/// Flow: single-pass character scan tracking string/escape state with a
|
||||
/// LIFO stack for `{`/`[` → append missing `"`, `]`, `}` in the right
|
||||
/// (reverse nesting) order.
|
||||
pub fn repair_json(s: &str) -> String {
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut prev_was_backslash = false;
|
||||
let mut ends_with_unclosed_escape = false;
|
||||
|
||||
for c in s.chars() {
|
||||
if prev_was_backslash {
|
||||
prev_was_backslash = false;
|
||||
ends_with_unclosed_escape = false;
|
||||
continue;
|
||||
}
|
||||
if c == '\\' && in_string {
|
||||
prev_was_backslash = true;
|
||||
ends_with_unclosed_escape = true;
|
||||
continue;
|
||||
}
|
||||
ends_with_unclosed_escape = false;
|
||||
if c == '"' {
|
||||
in_string = !in_string;
|
||||
continue;
|
||||
}
|
||||
if in_string {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'{' | '[' => stack.push(c),
|
||||
'}' | ']' => {
|
||||
stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = s.to_string();
|
||||
if ends_with_unclosed_escape {
|
||||
result.pop();
|
||||
}
|
||||
if in_string {
|
||||
result.push('"');
|
||||
}
|
||||
for &opener in stack.iter().rev() {
|
||||
match opener {
|
||||
'{' => result.push('}'),
|
||||
'[' => result.push(']'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Record of one completed tool invocation, kept for transcript/history.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Record of a completed tool invocation, kept for transcript/history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResult {
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
pub output: String,
|
||||
pub is_error: bool,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
impl ToolCallResult {
|
||||
/// Create a new tool call result.
|
||||
pub fn new(
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
duration_ms: u64,
|
||||
) -> Self {
|
||||
ToolCallResult {
|
||||
tool_call_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Token usage accounting shared by streaming and non-streaming responses.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
#[serde(default)]
|
||||
pub last_tokens_in: u64,
|
||||
#[serde(default)]
|
||||
pub last_tokens_out: u64,
|
||||
pub api_calls: u64,
|
||||
pub review_tokens: u64,
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
impl UsageStats {
|
||||
/// Create a new `UsageStats` with all counters zeroed.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Domain entity modules organised by concern — pure data structures with
|
||||
//! serde serialisation and filesystem persistence (serde JSON + std::fs).
|
||||
|
||||
pub mod auth;
|
||||
pub mod common;
|
||||
Reference in New Issue
Block a user