feat(backend): Rust core for projects, diagrams and Git

- sqlx/SQLite registry of known projects + user settings (db.rs)
- project create/open with mermix.toml config and slugged paths (project.rs)
- diagram CRUD on .mmd files with frontmatter titles and path-traversal
  guards (diagram.rs)
- git2-backed versioning: commit-all, history, branches, checkout,
  working-tree status (git_ops.rs)
- Tauri command surface, shared state and unified error type
- headless tests covering project creation, diagram CRUD and branch
  isolation (tests.rs)
This commit is contained in:
2026-05-22 16:27:20 +03:00
parent dfafea41f4
commit 890390bc65
11 changed files with 6807 additions and 0 deletions

209
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,209 @@
use std::path::{Path, PathBuf};
use serde::Serialize;
use tauri::State;
use crate::db::{self, ProjectRecord};
use crate::diagram::{self, DiagramInfo};
use crate::error::{AppError, Result};
use crate::git_ops::{self, BranchInfo, CommitInfo, FileStatus};
use crate::project::{self, ProjectConfig};
use crate::state::AppState;
/// Everything the frontend needs after opening or creating a project.
#[derive(Serialize)]
pub struct OpenedProject {
pub record: ProjectRecord,
pub config: ProjectConfig,
pub diagrams: Vec<DiagramInfo>,
pub branch: String,
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
fn record_from_config(config: &ProjectConfig, path: &Path) -> ProjectRecord {
ProjectRecord {
id: config.project.id.clone(),
name: config.project.name.clone(),
path: path.to_string_lossy().to_string(),
created_at: config.project.created_at.clone(),
last_opened_at: Some(now()),
}
}
// ---------------------------------------------------------------------------
// Project registry & lifecycle
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectRecord>> {
db::list_projects(&state.db).await
}
#[tauri::command]
pub async fn create_project(
state: State<'_, AppState>,
parent_dir: String,
name: String,
description: String,
) -> Result<OpenedProject> {
let dir = project::project_path_for(Path::new(&parent_dir), &name);
let config = project::create_project(&dir, &name, &description)?;
let record = record_from_config(&config, &dir);
db::upsert_project(&state.db, &record).await?;
let diagrams = diagram::list_diagrams(&dir)?;
let branch = git_ops::current_branch(&dir).unwrap_or_else(|_| "main".into());
Ok(OpenedProject {
record,
config,
diagrams,
branch,
})
}
#[tauri::command]
pub async fn open_project(state: State<'_, AppState>, path: String) -> Result<OpenedProject> {
let dir = PathBuf::from(&path);
if !project::is_project(&dir) {
return Err(AppError::NotAProject(path));
}
let config = project::read_config(&dir)?;
let record = record_from_config(&config, &dir);
db::upsert_project(&state.db, &record).await?;
db::touch_project(&state.db, &record.path, &now()).await?;
let diagrams = diagram::list_diagrams(&dir)?;
let branch = git_ops::current_branch(&dir).unwrap_or_else(|_| "main".into());
Ok(OpenedProject {
record,
config,
diagrams,
branch,
})
}
/// Remove a project from the registry. Files on disk are left untouched.
#[tauri::command]
pub async fn forget_project(state: State<'_, AppState>, id: String) -> Result<()> {
db::remove_project(&state.db, &id).await
}
// ---------------------------------------------------------------------------
// Diagrams
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn list_diagrams(path: String) -> Result<Vec<DiagramInfo>> {
diagram::list_diagrams(Path::new(&path))
}
#[tauri::command]
pub fn read_diagram(path: String, rel: String) -> Result<String> {
diagram::read_diagram(Path::new(&path), &rel)
}
#[tauri::command]
pub fn save_diagram(path: String, rel: String, content: String) -> Result<()> {
diagram::save_diagram(Path::new(&path), &rel, &content)
}
#[tauri::command]
pub fn create_diagram(path: String, name: String) -> Result<DiagramInfo> {
diagram::create_diagram(Path::new(&path), &name)
}
#[tauri::command]
pub fn rename_diagram(path: String, rel: String, new_name: String) -> Result<DiagramInfo> {
diagram::rename_diagram(Path::new(&path), &rel, &new_name)
}
#[tauri::command]
pub fn delete_diagram(path: String, rel: String) -> Result<()> {
diagram::delete_diagram(Path::new(&path), &rel)
}
// ---------------------------------------------------------------------------
// Git
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn git_status(path: String) -> Result<Vec<FileStatus>> {
git_ops::status(Path::new(&path))
}
#[tauri::command]
pub fn git_commit(path: String, message: String) -> Result<String> {
let msg = message.trim();
if msg.is_empty() {
return Err(AppError::other("commit message cannot be empty"));
}
git_ops::commit_all(Path::new(&path), msg)
}
#[tauri::command]
pub fn git_history(path: String, limit: Option<usize>) -> Result<Vec<CommitInfo>> {
git_ops::history(Path::new(&path), limit.unwrap_or(100))
}
#[tauri::command]
pub fn git_branches(path: String) -> Result<Vec<BranchInfo>> {
git_ops::branches(Path::new(&path))
}
#[tauri::command]
pub fn git_current_branch(path: String) -> Result<String> {
git_ops::current_branch(Path::new(&path))
}
#[tauri::command]
pub fn git_create_branch(path: String, name: String) -> Result<()> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::other("branch name cannot be empty"));
}
git_ops::create_branch(Path::new(&path), name)
}
#[tauri::command]
pub fn git_checkout_branch(path: String, name: String) -> Result<()> {
git_ops::checkout_branch(Path::new(&path), &name)
}
// ---------------------------------------------------------------------------
// Export helpers & settings
// ---------------------------------------------------------------------------
/// Write UTF-8 text to an arbitrary path chosen by the user (e.g. SVG export).
#[tauri::command]
pub fn write_text_file(path: String, contents: String) -> Result<()> {
std::fs::write(path, contents)?;
Ok(())
}
/// Write raw bytes to a path chosen by the user (e.g. PNG export).
#[tauri::command]
pub fn write_binary_file(path: String, contents: Vec<u8>) -> Result<()> {
std::fs::write(path, contents)?;
Ok(())
}
#[tauri::command]
pub async fn get_setting(state: State<'_, AppState>, key: String) -> Result<Option<String>> {
db::get_setting(&state.db, &key).await
}
#[tauri::command]
pub async fn set_setting(
state: State<'_, AppState>,
key: String,
value: String,
) -> Result<()> {
db::set_setting(&state.db, &key, &value).await
}