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
}

132
src-tauri/src/db.rs Normal file
View File

@@ -0,0 +1,132 @@
use std::path::Path;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
use crate::error::Result;
/// A row in the project registry — one per project known to this Mermix
/// installation. The actual project content lives on disk at `path`.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ProjectRecord {
pub id: String,
pub name: String,
pub path: String,
pub created_at: String,
pub last_opened_at: Option<String>,
}
/// Open (creating if necessary) the registry database and ensure its schema.
pub async fn init_pool(db_path: &Path) -> Result<SqlitePool> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await?;
ensure_schema(&pool).await?;
Ok(pool)
}
async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_opened_at TEXT
);
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"#,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_projects(pool: &SqlitePool) -> Result<Vec<ProjectRecord>> {
let rows = sqlx::query_as::<_, ProjectRecord>(
"SELECT id, name, path, created_at, last_opened_at \
FROM projects \
ORDER BY COALESCE(last_opened_at, created_at) DESC",
)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Insert a project, or update its name if a row with the same path exists.
pub async fn upsert_project(pool: &SqlitePool, record: &ProjectRecord) -> Result<()> {
sqlx::query(
"INSERT INTO projects (id, name, path, created_at, last_opened_at) \
VALUES (?, ?, ?, ?, ?) \
ON CONFLICT(path) DO UPDATE SET \
name = excluded.name, \
last_opened_at = excluded.last_opened_at",
)
.bind(&record.id)
.bind(&record.name)
.bind(&record.path)
.bind(&record.created_at)
.bind(&record.last_opened_at)
.execute(pool)
.await?;
Ok(())
}
pub async fn touch_project(pool: &SqlitePool, path: &str, now: &str) -> Result<()> {
sqlx::query("UPDATE projects SET last_opened_at = ? WHERE path = ?")
.bind(now)
.bind(path)
.execute(pool)
.await?;
Ok(())
}
/// Remove a project from the registry. Does NOT delete files on disk.
pub async fn remove_project(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("DELETE FROM projects WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(key)
.fetch_optional(pool)
.await?;
Ok(row.map(|(v,)| v))
}
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
sqlx::query(
"INSERT INTO settings (key, value) VALUES (?, ?) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(key)
.bind(value)
.execute(pool)
.await?;
Ok(())
}

182
src-tauri/src/diagram.rs Normal file
View File

@@ -0,0 +1,182 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::error::{AppError, Result};
use crate::project::{self, DIAGRAMS_DIR};
#[derive(Debug, Serialize)]
pub struct DiagramInfo {
/// Path relative to the project root, e.g. `diagrams/flow.mmd`. Used as a
/// stable identifier from the frontend.
pub id: String,
/// Display title (frontmatter `title:` if present, else the file stem).
pub name: String,
/// RFC3339 last-modified timestamp.
pub updated_at: String,
}
fn diagrams_root(project_dir: &Path) -> PathBuf {
project_dir.join(DIAGRAMS_DIR)
}
/// Extract a `title:` from a Mermaid YAML frontmatter block, if present.
fn title_from_source(source: &str) -> Option<String> {
let trimmed = source.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let mut lines = trimmed.lines();
lines.next(); // opening ---
for line in lines {
let line = line.trim();
if line == "---" {
break;
}
if let Some(rest) = line.strip_prefix("title:") {
let value = rest.trim().trim_matches(['"', '\'']);
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
fn modified_rfc3339(path: &Path) -> String {
fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339())
.unwrap_or_default()
}
pub fn list_diagrams(project_dir: &Path) -> Result<Vec<DiagramInfo>> {
let root = diagrams_root(project_dir);
if !root.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
collect(&root, project_dir, &mut out)?;
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(out)
}
fn collect(dir: &Path, project_dir: &Path, out: &mut Vec<DiagramInfo>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect(&path, project_dir, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("mmd") {
let rel = path
.strip_prefix(project_dir)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let source = fs::read_to_string(&path).unwrap_or_default();
let name = title_from_source(&source).unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled")
.to_string()
});
out.push(DiagramInfo {
id: rel,
name,
updated_at: modified_rfc3339(&path),
});
}
}
Ok(())
}
/// Resolve a project-relative diagram id to an absolute path, rejecting any
/// path that would escape the project directory.
fn resolve(project_dir: &Path, rel: &str) -> Result<PathBuf> {
let rel = rel.trim_start_matches(['/', '\\']);
if rel.contains("..") {
return Err(AppError::other("invalid diagram path"));
}
Ok(project_dir.join(rel))
}
pub fn read_diagram(project_dir: &Path, rel: &str) -> Result<String> {
let path = resolve(project_dir, rel)?;
if !path.is_file() {
return Err(AppError::DiagramNotFound(rel.to_string()));
}
Ok(fs::read_to_string(path)?)
}
pub fn save_diagram(project_dir: &Path, rel: &str, content: &str) -> Result<()> {
let path = resolve(project_dir, rel)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(())
}
pub fn create_diagram(project_dir: &Path, name: &str) -> Result<DiagramInfo> {
let root = diagrams_root(project_dir);
fs::create_dir_all(&root)?;
let base = project::slugify(name);
let mut filename = format!("{base}.mmd");
let mut counter = 2;
while root.join(&filename).exists() {
filename = format!("{base}-{counter}.mmd");
counter += 1;
}
let template = format!(
"---\ntitle: {name}\n---\nflowchart TD\n A[Start] --> B[End]\n"
);
let path = root.join(&filename);
fs::write(&path, template)?;
let rel = format!("{DIAGRAMS_DIR}/{filename}");
Ok(DiagramInfo {
id: rel,
name: name.to_string(),
updated_at: modified_rfc3339(&path),
})
}
pub fn delete_diagram(project_dir: &Path, rel: &str) -> Result<()> {
let path = resolve(project_dir, rel)?;
if path.is_file() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn rename_diagram(project_dir: &Path, rel: &str, new_name: &str) -> Result<DiagramInfo> {
let old = resolve(project_dir, rel)?;
if !old.is_file() {
return Err(AppError::DiagramNotFound(rel.to_string()));
}
let dir = old.parent().unwrap_or(project_dir);
let base = project::slugify(new_name);
let mut filename = format!("{base}.mmd");
let mut counter = 2;
while dir.join(&filename).exists() && dir.join(&filename) != old {
filename = format!("{base}-{counter}.mmd");
counter += 1;
}
let new_path = dir.join(&filename);
fs::rename(&old, &new_path)?;
let rel = new_path
.strip_prefix(project_dir)
.unwrap_or(&new_path)
.to_string_lossy()
.replace('\\', "/");
Ok(DiagramInfo {
id: rel,
name: new_name.to_string(),
updated_at: modified_rfc3339(&new_path),
})
}

55
src-tauri/src/error.rs Normal file
View File

@@ -0,0 +1,55 @@
use serde::{Serialize, Serializer};
/// Unified error type for all backend operations.
///
/// Implements [`Serialize`] so it can be returned directly from Tauri
/// commands; the frontend receives a human-readable string.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("git error: {0}")]
Git(#[from] git2::Error),
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("config parse error: {0}")]
TomlDe(#[from] toml::de::Error),
#[error("config write error: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("not a Mermix project: {0}")]
NotAProject(String),
#[error("project already exists at {0}")]
ProjectExists(String),
#[error("diagram not found: {0}")]
DiagramNotFound(String),
#[error("{0}")]
Other(String),
}
impl AppError {
pub fn other(msg: impl Into<String>) -> Self {
AppError::Other(msg.into())
}
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
pub type Result<T> = std::result::Result<T, AppError>;

190
src-tauri/src/git_ops.rs Normal file
View File

@@ -0,0 +1,190 @@
use std::path::Path;
use git2::{
BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions,
};
use serde::Serialize;
use crate::error::{AppError, Result};
#[derive(Debug, Serialize)]
pub struct CommitInfo {
pub id: String,
pub short_id: String,
pub message: String,
pub author: String,
pub email: String,
/// RFC3339 timestamp.
pub time: String,
}
#[derive(Debug, Serialize)]
pub struct BranchInfo {
pub name: String,
pub is_head: bool,
}
#[derive(Debug, Serialize)]
pub struct FileStatus {
pub path: String,
pub status: String,
}
/// Initialize a fresh repository with `main` as the default branch.
pub fn init_repo(dir: &Path) -> Result<Repository> {
let mut opts = RepositoryInitOptions::new();
opts.initial_head("main");
let repo = Repository::init_opts(dir, &opts)?;
Ok(repo)
}
/// Build a commit signature, falling back to a default identity when the
/// repository / global git config has none configured.
fn signature(repo: &Repository) -> Result<Signature<'static>> {
if let Ok(config) = repo.config() {
let name = config.get_string("user.name").ok();
let email = config.get_string("user.email").ok();
if let (Some(name), Some(email)) = (name, email) {
return Ok(Signature::now(&name, &email)?);
}
}
Ok(Signature::now("Mermix", "mermix@localhost")?)
}
/// Stage every change (additions, modifications, deletions) and create a
/// commit. Works for the very first commit (no parent) as well.
pub fn commit_all(dir: &Path, message: &str) -> Result<String> {
let repo = Repository::open(dir)?;
let mut index = repo.index()?;
index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
index.update_all(["*"].iter(), None)?;
index.write()?;
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let sig = signature(&repo)?;
let parent: Option<Commit> = match repo.head() {
Ok(head) => head.target().and_then(|oid| repo.find_commit(oid).ok()),
Err(_) => None,
};
let parents: Vec<&Commit> = parent.iter().collect();
let oid = repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?;
Ok(oid.to_string())
}
pub fn current_branch(dir: &Path) -> Result<String> {
let repo = Repository::open(dir)?;
let name = match repo.head() {
Ok(head) => head.shorthand().unwrap_or("HEAD").to_string(),
// Unborn branch (no commits yet) — default to main.
Err(_) => "main".to_string(),
};
Ok(name)
}
pub fn history(dir: &Path, limit: usize) -> Result<Vec<CommitInfo>> {
let repo = Repository::open(dir)?;
if repo.head().is_err() {
return Ok(Vec::new());
}
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
let mut commits = Vec::new();
for oid in revwalk.take(limit) {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let author = commit.author();
let secs = commit.time().seconds();
let time = chrono::DateTime::from_timestamp(secs, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_default();
commits.push(CommitInfo {
id: oid.to_string(),
short_id: oid.to_string()[..7.min(oid.to_string().len())].to_string(),
message: commit.summary().unwrap_or("").to_string(),
author: author.name().unwrap_or("").to_string(),
email: author.email().unwrap_or("").to_string(),
time,
});
}
Ok(commits)
}
pub fn branches(dir: &Path) -> Result<Vec<BranchInfo>> {
let repo = Repository::open(dir)?;
let head_name = current_branch(dir).unwrap_or_default();
let mut out = Vec::new();
for branch in repo.branches(Some(BranchType::Local))? {
let (branch, _) = branch?;
if let Some(name) = branch.name()? {
out.push(BranchInfo {
name: name.to_string(),
is_head: name == head_name,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn create_branch(dir: &Path, name: &str) -> Result<()> {
let repo = Repository::open(dir)?;
let head = repo
.head()
.map_err(|_| AppError::other("cannot branch before the first commit"))?
.peel_to_commit()?;
repo.branch(name, &head, false)?;
Ok(())
}
pub fn checkout_branch(dir: &Path, name: &str) -> Result<()> {
let repo = Repository::open(dir)?;
let (object, reference) = repo.revparse_ext(name)?;
repo.checkout_tree(&object, None)?;
match reference {
Some(reference) => {
let ref_name = reference
.name()
.ok_or_else(|| AppError::other("invalid reference name"))?;
repo.set_head(ref_name)?;
}
None => repo.set_head_detached(object.id())?,
}
Ok(())
}
pub fn status(dir: &Path) -> Result<Vec<FileStatus>> {
let repo = Repository::open(dir)?;
let mut opts = StatusOptions::new();
opts.include_untracked(true).recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut opts))?;
let mut out = Vec::new();
for entry in statuses.iter() {
let s = entry.status();
let label = if s.is_wt_new() || s.is_index_new() {
"added"
} else if s.is_wt_modified() || s.is_index_modified() {
"modified"
} else if s.is_wt_deleted() || s.is_index_deleted() {
"deleted"
} else if s.is_wt_renamed() || s.is_index_renamed() {
"renamed"
} else {
"changed"
};
out.push(FileStatus {
path: entry.path().unwrap_or("").to_string(),
status: label.to_string(),
});
}
Ok(out)
}

73
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,73 @@
mod commands;
mod db;
mod diagram;
mod error;
mod git_ops;
mod project;
mod state;
#[cfg(test)]
mod tests;
use std::path::PathBuf;
use directories::ProjectDirs;
use crate::state::AppState;
/// Resolve the directory where Mermix keeps its registry database.
fn resolve_data_dir() -> PathBuf {
if let Some(dirs) = ProjectDirs::from("dev", "Mermix", "Mermix") {
dirs.data_dir().to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".mermix")
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
use tauri::Manager;
let data_dir = resolve_data_dir();
std::fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join("mermix.db");
let pool = tauri::async_runtime::block_on(db::init_pool(&db_path))?;
app.manage(AppState {
db: pool,
data_dir,
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::list_projects,
commands::create_project,
commands::open_project,
commands::forget_project,
commands::list_diagrams,
commands::read_diagram,
commands::save_diagram,
commands::create_diagram,
commands::rename_diagram,
commands::delete_diagram,
commands::git_status,
commands::git_commit,
commands::git_history,
commands::git_branches,
commands::git_current_branch,
commands::git_create_branch,
commands::git_checkout_branch,
commands::write_text_file,
commands::write_binary_file,
commands::get_setting,
commands::set_setting,
])
.run(tauri::generate_context!())
.expect("error while running Mermix");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents an additional console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
mermix_lib::run()
}

140
src-tauri/src/project.rs Normal file
View File

@@ -0,0 +1,140 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{AppError, Result};
use crate::git_ops;
/// Name of the per-project configuration file that marks a directory as a
/// Mermix project.
pub const CONFIG_FILE: &str = "mermix.toml";
/// Sub-directory holding the diagram source files.
pub const DIAGRAMS_DIR: &str = "diagrams";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub project: ProjectMeta,
#[serde(default)]
pub defaults: ProjectDefaults,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMeta {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectDefaults {
#[serde(default = "default_theme")]
pub theme: String,
}
impl Default for ProjectDefaults {
fn default() -> Self {
ProjectDefaults {
theme: default_theme(),
}
}
}
fn default_theme() -> String {
"default".to_string()
}
pub fn is_project(dir: &Path) -> bool {
dir.join(CONFIG_FILE).is_file()
}
pub fn read_config(dir: &Path) -> Result<ProjectConfig> {
let path = dir.join(CONFIG_FILE);
if !path.is_file() {
return Err(AppError::NotAProject(dir.display().to_string()));
}
let raw = fs::read_to_string(&path)?;
let config: ProjectConfig = toml::from_str(&raw)?;
Ok(config)
}
pub fn write_config(dir: &Path, config: &ProjectConfig) -> Result<()> {
let path = dir.join(CONFIG_FILE);
let raw = toml::to_string_pretty(config)?;
fs::write(path, raw)?;
Ok(())
}
/// Create a brand-new project directory: writes the config, a starter diagram,
/// a README and `.gitignore`, then initializes Git with an initial commit.
pub fn create_project(dir: &Path, name: &str, description: &str) -> Result<ProjectConfig> {
if is_project(dir) {
return Err(AppError::ProjectExists(dir.display().to_string()));
}
if dir.exists() && dir.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) {
return Err(AppError::ProjectExists(format!(
"{} (directory is not empty)",
dir.display()
)));
}
fs::create_dir_all(dir.join(DIAGRAMS_DIR))?;
let now = chrono::Utc::now().to_rfc3339();
let config = ProjectConfig {
project: ProjectMeta {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
description: description.to_string(),
created_at: now,
},
defaults: ProjectDefaults::default(),
};
write_config(dir, &config)?;
// Starter diagram so the project is never empty.
let sample = "---\ntitle: Welcome\n---\nflowchart TD\n A[Start] --> B{Edit me}\n B -->|Save| C[Commit with Git]\n B -->|Export| D[SVG / PNG]\n";
fs::write(dir.join(DIAGRAMS_DIR).join("welcome.mmd"), sample)?;
let readme = format!(
"# {name}\n\n{description}\n\nMermaid diagram project managed by [Mermix](https://github.com/).\n\nDiagrams live in `./{DIAGRAMS_DIR}` as `.mmd` files and are versioned with Git.\n"
);
fs::write(dir.join("README.md"), readme)?;
fs::write(
dir.join(".gitignore"),
"# OS / editor noise\n.DS_Store\nThumbs.db\n*~\n",
)?;
git_ops::init_repo(dir)?;
git_ops::commit_all(dir, "Initial commit")?;
Ok(config)
}
/// Turn a human title into a filesystem-safe slug for filenames.
pub fn slugify(input: &str) -> String {
let mut slug = String::new();
let mut prev_dash = false;
for ch in input.trim().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash && !slug.is_empty() {
slug.push('-');
prev_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"untitled".to_string()
} else {
slug
}
}
/// Compute a default project directory path under `parent` for the given name.
pub fn project_path_for(parent: &Path, name: &str) -> PathBuf {
parent.join(slugify(name))
}

14
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,14 @@
use std::path::PathBuf;
use sqlx::SqlitePool;
/// Application-wide state shared across all Tauri command invocations.
pub struct AppState {
/// Connection pool for the app-level metadata database (project registry,
/// user settings). Per-project data lives on disk inside each Git repo.
pub db: SqlitePool,
/// Directory where Mermix stores its own data (the registry database).
/// Retained on the state for diagnostics and future use.
#[allow(dead_code)]
pub data_dir: PathBuf,
}

154
src-tauri/src/tests.rs Normal file
View File

@@ -0,0 +1,154 @@
//! Integration-style tests for the core (non-Tauri) logic: project creation,
//! diagram CRUD and Git versioning. These run fully headless via `cargo test`.
use std::fs;
use std::path::PathBuf;
use crate::{diagram, git_ops, project};
/// A unique temp directory that cleans itself up on drop.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("mermix-test-{}", uuid::Uuid::new_v4()));
TempDir(dir)
}
/// A not-yet-created project path inside this temp area.
fn project(&self) -> PathBuf {
self.0.join("proj")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn slugify_handles_messy_input() {
assert_eq!(project::slugify("Login Flow"), "login-flow");
assert_eq!(project::slugify(" Hello, World!! "), "hello-world");
assert_eq!(project::slugify("***"), "untitled");
assert_eq!(project::slugify("Already-Slug"), "already-slug");
}
#[test]
fn create_project_writes_config_and_initial_commit() {
let tmp = TempDir::new();
let dir = tmp.project();
let config = project::create_project(&dir, "My Diagrams", "test project").unwrap();
assert_eq!(config.project.name, "My Diagrams");
assert!(!config.project.id.is_empty());
assert_eq!(config.defaults.theme, "default");
// Marker file + structure.
assert!(project::is_project(&dir));
assert!(dir.join("README.md").is_file());
assert!(dir.join(".gitignore").is_file());
assert!(dir.join("diagrams/welcome.mmd").is_file());
// Round-trips through TOML.
let read = project::read_config(&dir).unwrap();
assert_eq!(read.project.id, config.project.id);
// Git repo exists with exactly one commit on `main`.
assert_eq!(git_ops::current_branch(&dir).unwrap(), "main");
let history = git_ops::history(&dir, 10).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].message, "Initial commit");
}
#[test]
fn create_project_rejects_existing() {
let tmp = TempDir::new();
let dir = tmp.project();
project::create_project(&dir, "First", "").unwrap();
let err = project::create_project(&dir, "Again", "");
assert!(err.is_err(), "creating over an existing project must fail");
}
#[test]
fn diagram_crud_round_trip() {
let tmp = TempDir::new();
let dir = tmp.project();
project::create_project(&dir, "Proj", "").unwrap();
// Starts with the welcome diagram.
let initial = diagram::list_diagrams(&dir).unwrap();
assert_eq!(initial.len(), 1);
// Create.
let created = diagram::create_diagram(&dir, "Login Flow").unwrap();
assert_eq!(created.id, "diagrams/login-flow.mmd");
assert_eq!(created.name, "Login Flow");
// Title comes from frontmatter, not the filename.
let listed = diagram::list_diagrams(&dir).unwrap();
assert_eq!(listed.len(), 2);
assert!(listed.iter().any(|d| d.name == "Login Flow"));
// Save + read.
let body = "---\ntitle: Login Flow\n---\nflowchart LR\n A-->B\n";
diagram::save_diagram(&dir, &created.id, body).unwrap();
assert_eq!(diagram::read_diagram(&dir, &created.id).unwrap(), body);
// Name collision picks a new slug.
let dup = diagram::create_diagram(&dir, "Login Flow").unwrap();
assert_eq!(dup.id, "diagrams/login-flow-2.mmd");
// Rename.
let renamed = diagram::rename_diagram(&dir, &created.id, "Auth Flow").unwrap();
assert_eq!(renamed.id, "diagrams/auth-flow.mmd");
assert!(diagram::read_diagram(&dir, &created.id).is_err());
// Delete.
diagram::delete_diagram(&dir, &renamed.id).unwrap();
assert!(diagram::read_diagram(&dir, &renamed.id).is_err());
}
#[test]
fn diagram_path_traversal_is_rejected() {
let tmp = TempDir::new();
let dir = tmp.project();
project::create_project(&dir, "Proj", "").unwrap();
assert!(diagram::read_diagram(&dir, "../../etc/passwd").is_err());
assert!(diagram::save_diagram(&dir, "../escape.mmd", "x").is_err());
}
#[test]
fn git_commit_and_branching() {
let tmp = TempDir::new();
let dir = tmp.project();
project::create_project(&dir, "Proj", "").unwrap();
// A new diagram is an uncommitted change.
diagram::create_diagram(&dir, "Second").unwrap();
let status = git_ops::status(&dir).unwrap();
assert!(!status.is_empty(), "new file should appear in status");
// Commit it; history grows and the tree goes clean.
git_ops::commit_all(&dir, "Add second diagram").unwrap();
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2);
assert!(git_ops::status(&dir).unwrap().is_empty());
// Branch off and switch.
git_ops::create_branch(&dir, "feature").unwrap();
let branches = git_ops::branches(&dir).unwrap();
assert!(branches.iter().any(|b| b.name == "feature"));
assert!(branches.iter().any(|b| b.name == "main"));
git_ops::checkout_branch(&dir, "feature").unwrap();
assert_eq!(git_ops::current_branch(&dir).unwrap(), "feature");
// A commit on the feature branch is isolated from main.
diagram::create_diagram(&dir, "Feature Only").unwrap();
git_ops::commit_all(&dir, "feature work").unwrap();
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 3);
git_ops::checkout_branch(&dir, "main").unwrap();
assert_eq!(git_ops::current_branch(&dir).unwrap(), "main");
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2);
}