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:
5652
src-tauri/Cargo.lock
generated
Normal file
5652
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
209
src-tauri/src/commands.rs
Normal file
209
src-tauri/src/commands.rs
Normal 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
132
src-tauri/src/db.rs
Normal 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
182
src-tauri/src/diagram.rs
Normal 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
55
src-tauri/src/error.rs
Normal 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
190
src-tauri/src/git_ops.rs
Normal 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
73
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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
140
src-tauri/src/project.rs
Normal 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
14
src-tauri/src/state.rs
Normal 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
154
src-tauri/src/tests.rs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user