use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
use std::fmt;
use std::fs;
use std::fs::OpenOptions;
use std::io;
use std::io::Seek;
use std::io::SeekFrom;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use crate::format::mermaid::{
export_flowchart, export_sequence_diagram, parse_flowchart, parse_sequence_diagram,
MermaidFlowchartExportError, MermaidFlowchartParseError, MermaidSequenceExportError,
MermaidSequenceParseError,
};
use crate::layout::{layout_flowchart, layout_sequence, FlowchartLayoutError, SequenceLayoutError};
use crate::model::{
Diagram, DiagramAst, DiagramId, DiagramKind, FlowEdge, FlowNode, FlowchartAst, IdError,
ObjectId, ObjectRef, ParseObjectRefError, SequenceAst, SequenceMessage, SequenceMessageKind,
Session, SessionId, Walkthrough, WalkthroughEdge, WalkthroughId, WalkthroughNode,
WalkthroughNodeId, XRef, XRefId, XRefStatus as ModelXRefStatus,
};
use crate::render::{
render_flowchart_unicode, render_sequence_unicode, render_walkthrough_unicode,
FlowchartRenderError, SequenceRenderError, WalkthroughRenderError,
};
const SESSION_META_FILENAME: &str = "nereid-session.meta.json";
const LEGACY_SESSION_META_FILENAME: &str = "session.meta.json";
const SESSION_WRITE_LOCK_FILENAME: &str = ".nereid-session.write.lock";
const SESSION_WRITE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(10);
const SESSION_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
enum AsciiExportTask {
Diagram {
session_dir: PathBuf,
mmd_path: PathBuf,
text_path: PathBuf,
durability: WriteDurability,
ast: DiagramAst,
},
Walkthrough {
session_dir: PathBuf,
json_path: PathBuf,
text_path: PathBuf,
durability: WriteDurability,
walkthrough: Walkthrough,
},
}
impl AsciiExportTask {
fn output_path(&self) -> &Path {
match self {
Self::Diagram { text_path, .. } | Self::Walkthrough { text_path, .. } => text_path,
}
}
fn session_dir(&self) -> &Path {
match self {
Self::Diagram { session_dir, .. } | Self::Walkthrough { session_dir, .. } => {
session_dir
}
}
}
}
#[derive(Debug, Default)]
struct AsciiExportState {
pending: HashMap<PathBuf, AsciiExportTask>,
queue: VecDeque<PathBuf>,
in_flight_session_dir: Option<PathBuf>,
}
#[derive(Debug)]
struct AsciiExportInner {
state: Mutex<AsciiExportState>,
cv: Condvar,
}
#[derive(Debug)]
struct AsciiExportManager {
inner: Arc<AsciiExportInner>,
}
impl AsciiExportManager {
fn new() -> Self {
let inner = Arc::new(AsciiExportInner {
state: Mutex::new(AsciiExportState::default()),
cv: Condvar::new(),
});
std::thread::Builder::new()
.name("nereid-ascii-export".to_owned())
.spawn({
let inner = inner.clone();
move || Self::run_worker(inner)
})
.expect("spawn ascii export worker thread");
Self { inner }
}
fn schedule(&self, task: AsciiExportTask) {
let output_path = task.output_path().to_path_buf();
let mut state = self.inner.state.lock().expect("ascii export lock poisoned");
match state.pending.entry(output_path.clone()) {
Entry::Occupied(mut entry) => {
entry.insert(task);
return;
}
Entry::Vacant(entry) => {
entry.insert(task);
}
}
state.queue.push_back(output_path);
self.inner.cv.notify_one();
}
fn cancel(&self, output_path: &Path) {
let mut state = self.inner.state.lock().expect("ascii export lock poisoned");
state.pending.remove(output_path);
}
fn flush_session_dir(&self, session_dir: &Path) {
let mut state = self.inner.state.lock().expect("ascii export lock poisoned");
while state.in_flight_session_dir.as_deref().is_some_and(|active| active == session_dir)
|| state.pending.values().any(|task| task.session_dir() == session_dir)
{
state = self.inner.cv.wait(state).expect("ascii export cv poisoned");
}
}
fn run_worker(inner: Arc<AsciiExportInner>) {
loop {
let task = {
let mut state = inner.state.lock().expect("ascii export lock poisoned");
loop {
if let Some(output_path) = state.queue.pop_front() {
if let Some(task) = state.pending.remove(&output_path) {
state.in_flight_session_dir = Some(task.session_dir().to_path_buf());
break task;
}
}
state = inner.cv.wait(state).expect("ascii export cv poisoned");
}
};
match task {
AsciiExportTask::Diagram { session_dir, mmd_path, text_path, durability, ast } => {
if !mmd_path.is_file() {
} else if let Some(mut text) = match ast {
DiagramAst::Sequence(ast) => match layout_sequence(&ast) {
Ok(layout) => render_sequence_unicode(&ast, &layout).ok(),
Err(_) => None,
},
DiagramAst::Flowchart(ast) => match layout_flowchart(&ast) {
Ok(layout) => render_flowchart_unicode(&ast, &layout).ok(),
Err(_) => None,
},
} {
if !text.ends_with('\n') {
text.push('\n');
}
let _ = write_atomic_in_session_if_session_dir_exists(
&session_dir,
&text_path,
text.as_bytes(),
durability,
);
}
}
AsciiExportTask::Walkthrough {
session_dir,
json_path,
text_path,
durability,
walkthrough,
} => {
if !json_path.is_file() {
} else if let Ok(mut text) = render_walkthrough_unicode(&walkthrough) {
if !text.ends_with('\n') {
text.push('\n');
}
let _ = write_atomic_in_session_if_session_dir_exists(
&session_dir,
&text_path,
text.as_bytes(),
durability,
);
}
}
}
let mut state = inner.state.lock().expect("ascii export lock poisoned");
state.in_flight_session_dir = None;
inner.cv.notify_all();
}
}
}
static ASCII_EXPORTS: OnceLock<AsciiExportManager> = OnceLock::new();
fn ascii_exports() -> &'static AsciiExportManager {
ASCII_EXPORTS.get_or_init(AsciiExportManager::new)
}
#[derive(Debug)]
pub enum StoreError {
Io {
path: PathBuf,
source: io::Error,
},
Json {
path: PathBuf,
source: serde_json::Error,
},
MermaidSequenceParse {
diagram_id: DiagramId,
path: PathBuf,
source: Box<MermaidSequenceParseError>,
},
MermaidFlowchartParse {
diagram_id: DiagramId,
path: PathBuf,
source: Box<MermaidFlowchartParseError>,
},
MermaidSequenceExport {
diagram_id: DiagramId,
path: PathBuf,
source: Box<MermaidSequenceExportError>,
},
MermaidFlowchartExport {
diagram_id: DiagramId,
path: PathBuf,
source: Box<MermaidFlowchartExportError>,
},
SequenceLayout {
diagram_id: DiagramId,
path: PathBuf,
source: Box<SequenceLayoutError>,
},
SequenceRender {
diagram_id: DiagramId,
path: PathBuf,
source: Box<SequenceRenderError>,
},
FlowchartLayout {
diagram_id: DiagramId,
path: PathBuf,
source: Box<FlowchartLayoutError>,
},
FlowchartRender {
diagram_id: DiagramId,
path: PathBuf,
source: Box<FlowchartRenderError>,
},
WalkthroughRender {
walkthrough_id: WalkthroughId,
path: PathBuf,
source: Box<WalkthroughRenderError>,
},
InvalidId {
field: &'static str,
value: String,
source: Box<IdError>,
},
InvalidObjectRef {
field: &'static str,
value: String,
source: Box<ParseObjectRefError>,
},
InvalidRelativePath {
field: &'static str,
value: PathBuf,
},
PathOutsideSession {
session_dir: PathBuf,
path: PathBuf,
},
SymlinkRefused {
path: PathBuf,
},
SessionWriteLockTimeout {
path: PathBuf,
},
MetaMissingWithExistingDiagrams {
meta_path: PathBuf,
diagrams_dir: PathBuf,
},
}
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => write!(f, "io error at {path:?}: {source}"),
Self::Json { path, source } => write!(f, "json error at {path:?}: {source}"),
Self::MermaidSequenceParse {
diagram_id,
path,
source,
} => write!(
f,
"cannot parse Mermaid sequence diagram {diagram_id} from {path:?}: {source}"
),
Self::MermaidFlowchartParse {
diagram_id,
path,
source,
} => write!(
f,
"cannot parse Mermaid flowchart diagram {diagram_id} from {path:?}: {source}"
),
Self::MermaidSequenceExport {
diagram_id,
path,
source,
} => write!(
f,
"cannot export Mermaid sequence diagram {diagram_id} to {path:?}: {source}"
),
Self::MermaidFlowchartExport {
diagram_id,
path,
source,
} => write!(
f,
"cannot export Mermaid flowchart diagram {diagram_id} to {path:?}: {source}"
),
Self::SequenceLayout {
diagram_id,
path,
source,
} => write!(
f,
"cannot layout sequence diagram {diagram_id} for unicode export to {path:?}: {source}"
),
Self::SequenceRender {
diagram_id,
path,
source,
} => write!(
f,
"cannot render sequence diagram {diagram_id} for unicode export to {path:?}: {source}"
),
Self::FlowchartLayout {
diagram_id,
path,
source,
} => write!(
f,
"cannot layout flowchart diagram {diagram_id} for unicode export to {path:?}: {source}"
),
Self::FlowchartRender {
diagram_id,
path,
source,
} => write!(
f,
"cannot render flowchart diagram {diagram_id} for unicode export to {path:?}: {source}"
),
Self::WalkthroughRender {
walkthrough_id,
path,
source,
} => write!(
f,
"cannot render walkthrough {walkthrough_id} for unicode export to {path:?}: {source}"
),
Self::InvalidId {
field,
value,
source,
} => write!(f, "invalid id for {field}: {value:?}: {source}"),
Self::InvalidObjectRef {
field,
value,
source,
} => write!(f, "invalid object ref for {field}: {value:?}: {source}"),
Self::InvalidRelativePath { field, value } => {
write!(f, "invalid relative path for {field}: {value:?}")
}
Self::PathOutsideSession { session_dir, path } => write!(
f,
"path is outside session dir: session_dir={session_dir:?} path={path:?}"
),
Self::SymlinkRefused { path } => {
write!(f, "refusing to write through symlink at {path:?}")
}
Self::SessionWriteLockTimeout { path } => {
write!(f, "timed out waiting for session write lock at {path:?}")
}
Self::MetaMissingWithExistingDiagrams { meta_path, diagrams_dir } => write!(
f,
"session meta {meta_path:?} is missing but diagram files exist in {diagrams_dir:?}; \
refusing to seed a fresh session and orphan them (restore the meta index to repair)"
),
}
}
}
impl std::error::Error for StoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Json { source, .. } => Some(source),
Self::MermaidSequenceParse { source, .. } => Some(source),
Self::MermaidFlowchartParse { source, .. } => Some(source),
Self::MermaidSequenceExport { source, .. } => Some(source),
Self::MermaidFlowchartExport { source, .. } => Some(source),
Self::SequenceLayout { source, .. } => Some(source),
Self::SequenceRender { source, .. } => Some(source),
Self::FlowchartLayout { source, .. } => Some(source),
Self::FlowchartRender { source, .. } => Some(source),
Self::WalkthroughRender { source, .. } => Some(source),
Self::InvalidId { source, .. } => Some(source),
Self::InvalidObjectRef { source, .. } => Some(source),
Self::InvalidRelativePath { .. } => None,
Self::PathOutsideSession { .. } => None,
Self::SymlinkRefused { .. } => None,
Self::SessionWriteLockTimeout { .. } => None,
Self::MetaMissingWithExistingDiagrams { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMeta {
pub session_id: SessionId,
pub active_diagram_id: Option<DiagramId>,
pub active_walkthrough_id: Option<WalkthroughId>,
pub walkthrough_ids: Option<Vec<WalkthroughId>>,
pub diagrams: Vec<SessionMetaDiagram>,
pub xrefs: Vec<SessionXRef>,
pub selected_object_refs: Vec<ObjectRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMetaDiagram {
pub diagram_id: DiagramId,
pub name: String,
pub kind: DiagramKind,
pub mmd_path: PathBuf,
pub rev: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionXRef {
pub xref_id: XRefId,
pub from: ObjectRef,
pub to: ObjectRef,
pub kind: String,
pub label: Option<String>,
pub status: ModelXRefStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagramMeta {
pub diagram_id: DiagramId,
pub mmd_path: PathBuf,
pub stable_id_map: DiagramStableIdMap,
pub xrefs: Vec<DiagramXRef>,
pub flow_edges: Vec<DiagramFlowEdgeMeta>,
pub sequence_messages: Vec<DiagramSequenceMessageMeta>,
pub flow_node_notes: BTreeMap<ObjectId, String>,
pub sequence_participant_notes: BTreeMap<ObjectId, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagramStableIdMap {
pub by_mermaid_id: BTreeMap<String, String>,
pub by_name: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagramXRef {
pub xref_id: String,
pub from: String,
pub to: String,
pub kind: String,
pub label: Option<String>,
pub status: XRefStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagramFlowEdgeMeta {
pub edge_id: ObjectId,
pub from_node_id: ObjectId,
pub to_node_id: ObjectId,
pub label: Option<String>,
pub style: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagramSequenceMessageMeta {
pub message_id: ObjectId,
pub from_participant_id: ObjectId,
pub to_participant_id: ObjectId,
pub kind: SequenceMessageKind,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum XRefStatus {
Ok,
DanglingFrom,
DanglingTo,
DanglingBoth,
}
#[derive(Debug, Clone)]
pub struct SessionFolder {
root: PathBuf,
durability: WriteDurability,
meta_lock: Arc<Mutex<()>>,
}
#[derive(Debug)]
struct DiagramArtifactsSnapshot {
mmd_path: PathBuf,
meta_path: PathBuf,
mmd_contents: Option<Vec<u8>>,
meta_contents: Option<Vec<u8>>,
}
#[derive(Debug)]
struct WalkthroughArtifactsSnapshot {
json_path: PathBuf,
ascii_path: PathBuf,
json_contents: Option<Vec<u8>>,
ascii_contents: Option<Vec<u8>>,
}
#[derive(Debug)]
pub struct SessionUpdate<'a> {
folder: &'a SessionFolder,
_guard: SessionWriteGuard<'a>,
session: Option<Session>,
}
impl<'a> SessionUpdate<'a> {
pub fn session(&self) -> &Session {
self.session.as_ref().expect("session update is committed")
}
pub fn session_mut(&mut self) -> &mut Session {
self.session.as_mut().expect("session update is committed")
}
pub fn commit(mut self) -> Result<Session, StoreError> {
let session = self.session.take().expect("session update is committed once");
self.folder.save_session_locked(&session)?;
Ok(session)
}
}
#[derive(Debug)]
struct SessionWriteGuard<'a> {
_meta_guard: std::sync::MutexGuard<'a, ()>,
lock_path: PathBuf,
lock_file: fs::File,
}
impl Drop for SessionWriteGuard<'_> {
fn drop(&mut self) {
if self.lock_file.set_len(0).is_err() {
let _ = &self.lock_path;
}
let _ = FileExt::unlock(&self.lock_file);
}
}
fn try_lock_session_file(file: &fs::File, path: &Path) -> Result<bool, StoreError> {
file.try_lock_exclusive().map(|()| true).or_else(|source| {
if source.kind() == io::ErrorKind::WouldBlock {
Ok(false)
} else {
Err(StoreError::Io { path: path.to_path_buf(), source })
}
})
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum WriteDurability {
#[default]
BestEffort,
Durable,
}
fn encode_persisted_id_segment(segment: &str) -> String {
if !needs_windows_safe_filename_segment_encoding(segment) {
return segment.to_owned();
}
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(1 + segment.len().saturating_mul(2));
out.push('~');
for &b in segment.as_bytes() {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn validate_legacy_persisted_id_segment(
field: &'static str,
segment: &str,
) -> Result<(), StoreError> {
if segment.contains('/') || segment.contains('\\') {
return Err(StoreError::InvalidRelativePath { field, value: PathBuf::from(segment) });
}
Ok(())
}
fn needs_windows_safe_filename_segment_encoding(segment: &str) -> bool {
if segment.starts_with('~') {
return true;
}
if segment == "." || segment == ".." {
return true;
}
if segment.ends_with(' ') || segment.ends_with('.') {
return true;
}
let trimmed = segment.trim_end_matches([' ', '.']);
let base = trimmed.split('.').next().unwrap_or(trimmed);
if is_windows_device_name(base) {
return true;
}
for ch in segment.chars() {
if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
return true;
}
if ch <= '\u{1f}' || ch == '\u{7f}' {
return true;
}
}
false
}
fn is_windows_device_name(base: &str) -> bool {
let base = base.to_ascii_uppercase();
match base.as_str() {
"CON" | "PRN" | "AUX" | "NUL" => true,
_ => {
if let Some(num) = base.strip_prefix("COM") {
matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
} else if let Some(num) = base.strip_prefix("LPT") {
matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
} else {
false
}
}
}
}
impl SessionFolder {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
durability: WriteDurability::default(),
meta_lock: Arc::new(Mutex::new(())),
}
}
fn lock_meta(&self) -> std::sync::MutexGuard<'_, ()> {
self.meta_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn session_write_lock_path(&self) -> PathBuf {
self.root.join(SESSION_WRITE_LOCK_FILENAME)
}
fn lock_session_write(&self) -> Result<SessionWriteGuard<'_>, StoreError> {
let meta_guard = self.lock_meta();
fs::create_dir_all(&self.root)
.map_err(|source| StoreError::Io { path: self.root.clone(), source })?;
let lock_path = self.session_write_lock_path();
let mut lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|source| StoreError::Io { path: lock_path.clone(), source })?;
let started = Instant::now();
loop {
match try_lock_session_file(&lock_file, &lock_path) {
Ok(true) => {
lock_file
.set_len(0)
.map_err(|source| StoreError::Io { path: lock_path.clone(), source })?;
lock_file
.seek(SeekFrom::Start(0))
.map_err(|source| StoreError::Io { path: lock_path.clone(), source })?;
writeln!(
lock_file,
"pid={} acquired_unix_ms={}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
)
.map_err(|source| StoreError::Io { path: lock_path.clone(), source })?;
return Ok(SessionWriteGuard { _meta_guard: meta_guard, lock_path, lock_file });
}
Ok(false) => {
if started.elapsed() >= SESSION_WRITE_LOCK_TIMEOUT {
return Err(StoreError::SessionWriteLockTimeout { path: lock_path });
}
std::thread::sleep(SESSION_WRITE_LOCK_RETRY_DELAY);
}
Err(err) => return Err(err),
}
}
}
pub fn with_durability(mut self, durability: WriteDurability) -> Self {
self.durability = durability;
self
}
pub fn durability(&self) -> WriteDurability {
self.durability
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn meta_path(&self) -> PathBuf {
self.root.join(SESSION_META_FILENAME)
}
fn legacy_meta_path(&self) -> PathBuf {
self.root.join(LEGACY_SESSION_META_FILENAME)
}
pub fn default_diagram_mmd_path(&self, diagram_id: &DiagramId) -> PathBuf {
let file_stem = encode_persisted_id_segment(diagram_id.as_str());
self.root.join("diagrams").join(format!("{file_stem}.mmd"))
}
pub fn diagram_ascii_path(&self, mmd_path: &Path) -> Result<PathBuf, StoreError> {
let relative_mmd_path = to_relative_path(self.root(), mmd_path, "mmd_path")?;
let relative_ascii_path = relative_mmd_path.with_extension("ascii.txt");
Ok(self.root.join(relative_ascii_path))
}
pub fn diagram_meta_path(&self, mmd_path: &Path) -> Result<PathBuf, StoreError> {
let relative_mmd_path = to_relative_path(self.root(), mmd_path, "mmd_path")?;
let relative_meta_path = relative_mmd_path.with_extension("meta.json");
Ok(self.root.join(relative_meta_path))
}
pub fn walkthrough_json_path(&self, walkthrough_id: &WalkthroughId) -> PathBuf {
let file_stem = encode_persisted_id_segment(walkthrough_id.as_str());
self.root.join("walkthroughs").join(format!("{file_stem}.wt.json"))
}
fn legacy_walkthrough_json_path(
&self,
walkthrough_id: &WalkthroughId,
) -> Result<PathBuf, StoreError> {
validate_legacy_persisted_id_segment("walkthrough_id", walkthrough_id.as_str())?;
Ok(self.root.join("walkthroughs").join(format!("{}.wt.json", walkthrough_id.as_str())))
}
pub fn walkthrough_ascii_path(&self, walkthrough_id: &WalkthroughId) -> PathBuf {
let file_stem = encode_persisted_id_segment(walkthrough_id.as_str());
self.root.join("walkthroughs").join(format!("{file_stem}.ascii.txt"))
}
pub fn flush_ascii_exports(&self) {
ascii_exports().flush_session_dir(self.root());
}
fn initial_session_id(&self) -> SessionId {
let candidate = self
.root
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.map(|name| format!("s:{name}"))
.unwrap_or_else(|| "s:session".to_owned());
SessionId::new(candidate).unwrap_or_else(|_| {
SessionId::new("s:session").expect("hard-coded fallback session id is valid")
})
}
fn initial_session(&self) -> Session {
let mut session = Session::new(self.initial_session_id());
let diagram_id = DiagramId::new("flow").expect("hard-coded initial diagram id is valid");
let node_id = ObjectId::new("n:hello").expect("hard-coded initial node id is valid");
let mut ast = FlowchartAst::default();
ast.nodes_mut().insert(node_id, FlowNode::new("Hello"));
let diagram = Diagram::new(diagram_id.clone(), "Flow", DiagramAst::Flowchart(ast));
session.diagrams_mut().insert(diagram_id.clone(), diagram);
session.set_active_diagram_id(Some(diagram_id));
session
}
pub fn load_or_init_session(&self) -> Result<Session, StoreError> {
match self.load_session() {
Ok(session) => Ok(session),
Err(StoreError::Io { path, source })
if source.kind() == io::ErrorKind::NotFound && path == self.meta_path() =>
{
if self.has_existing_diagram_files()? {
return Err(StoreError::MetaMissingWithExistingDiagrams {
meta_path: self.meta_path(),
diagrams_dir: self.root.join("diagrams"),
});
}
let session = self.initial_session();
self.save_session(&session)?;
Ok(session)
}
Err(err) => Err(err),
}
}
fn has_existing_diagram_files(&self) -> Result<bool, StoreError> {
let diagrams_dir = self.root.join("diagrams");
let entries = match fs::read_dir(&diagrams_dir) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(source) => return Err(StoreError::Io { path: diagrams_dir, source }),
};
for entry in entries {
let entry =
entry.map_err(|source| StoreError::Io { path: diagrams_dir.clone(), source })?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("mmd") && path.is_file() {
return Ok(true);
}
}
Ok(false)
}
pub fn save_session(&self, session: &Session) -> Result<(), StoreError> {
let _guard = self.lock_session_write()?;
self.save_session_locked(session)
}
pub fn begin_session_update(&self) -> Result<SessionUpdate<'_>, StoreError> {
let guard = self.lock_session_write()?;
let session = self.load_session()?;
Ok(SessionUpdate { folder: self, _guard: guard, session: Some(session) })
}
fn read_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>, StoreError> {
match fs::read(path) {
Ok(contents) => Ok(Some(contents)),
Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(StoreError::Io { path: path.to_path_buf(), source }),
}
}
fn snapshot_diagram_artifacts(
&self,
mmd_path: &Path,
meta_path: &Path,
) -> Result<DiagramArtifactsSnapshot, StoreError> {
Ok(DiagramArtifactsSnapshot {
mmd_path: mmd_path.to_path_buf(),
meta_path: meta_path.to_path_buf(),
mmd_contents: Self::read_file_snapshot(mmd_path)?,
meta_contents: Self::read_file_snapshot(meta_path)?,
})
}
fn snapshot_walkthrough_artifacts(
&self,
walkthrough_id: &WalkthroughId,
) -> Result<WalkthroughArtifactsSnapshot, StoreError> {
let json_path = self.walkthrough_json_path(walkthrough_id);
let ascii_path = self.walkthrough_ascii_path(walkthrough_id);
Ok(WalkthroughArtifactsSnapshot {
json_path: json_path.clone(),
ascii_path: ascii_path.clone(),
json_contents: Self::read_file_snapshot(&json_path)?,
ascii_contents: Self::read_file_snapshot(&ascii_path)?,
})
}
fn restore_file_snapshot(
&self,
path: &Path,
contents: &Option<Vec<u8>>,
) -> Result<(), StoreError> {
match contents {
Some(contents) => {
write_atomic_in_session(self.root(), path, contents, self.durability)?;
}
None => match fs::remove_file(path) {
Ok(()) => {}
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(source) => return Err(StoreError::Io { path: path.to_path_buf(), source }),
},
}
Ok(())
}
fn restore_file_snapshot_unless_symlink(
&self,
path: &Path,
contents: &Option<Vec<u8>>,
) -> Result<(), StoreError> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Ok(()),
Ok(_) | Err(_) => self.restore_file_snapshot(path, contents),
}
}
fn restore_diagram_artifacts(
&self,
snapshot: &DiagramArtifactsSnapshot,
) -> Result<(), StoreError> {
self.restore_file_snapshot(&snapshot.mmd_path, &snapshot.mmd_contents)?;
self.restore_file_snapshot(&snapshot.meta_path, &snapshot.meta_contents)?;
Ok(())
}
fn restore_walkthrough_artifacts(
&self,
snapshot: &WalkthroughArtifactsSnapshot,
) -> Result<(), StoreError> {
self.restore_file_snapshot(&snapshot.json_path, &snapshot.json_contents)?;
self.restore_file_snapshot(&snapshot.ascii_path, &snapshot.ascii_contents)?;
Ok(())
}
fn rollback_diagram_artifacts(
&self,
snapshots: &[DiagramArtifactsSnapshot],
) -> Result<(), StoreError> {
for snapshot in snapshots.iter().rev() {
self.restore_diagram_artifacts(snapshot)?;
}
Ok(())
}
fn rollback_session_artifacts(
&self,
diagram_snapshots: &[DiagramArtifactsSnapshot],
walkthrough_snapshots: &[WalkthroughArtifactsSnapshot],
) -> Result<(), StoreError> {
self.rollback_diagram_artifacts(diagram_snapshots)?;
for snapshot in walkthrough_snapshots.iter().rev() {
self.restore_walkthrough_artifacts(snapshot)?;
}
Ok(())
}
fn rollback_session_artifacts_then<T>(
&self,
diagram_snapshots: &[DiagramArtifactsSnapshot],
walkthrough_snapshots: &[WalkthroughArtifactsSnapshot],
err: StoreError,
) -> Result<T, StoreError> {
self.rollback_session_artifacts(diagram_snapshots, walkthrough_snapshots)?;
Err(err)
}
fn save_diagram_artifacts(
&self,
diagram: &Diagram,
mmd_path: &Path,
meta_path: &Path,
diagram_meta: &DiagramMeta,
) -> Result<DiagramArtifactsSnapshot, StoreError> {
let snapshot = self.snapshot_diagram_artifacts(mmd_path, meta_path)?;
self.save_diagram_meta(diagram_meta)?;
if let Err(err) = export_diagram_mmd(self, diagram, mmd_path) {
self.restore_file_snapshot(&snapshot.meta_path, &snapshot.meta_contents)?;
self.restore_file_snapshot_unless_symlink(&snapshot.mmd_path, &snapshot.mmd_contents)?;
return Err(err);
}
Ok(snapshot)
}
fn save_session_locked(&self, session: &Session) -> Result<(), StoreError> {
#[derive(Debug, Deserialize)]
struct WalkthroughRevJson {
#[serde(default)]
rev: u64,
}
let existing_meta = match self.load_meta() {
Ok(meta) => Some(meta),
Err(StoreError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => None,
Err(err) => return Err(err),
};
let mut existing_diagram_revs = BTreeMap::<DiagramId, u64>::new();
let mut existing_walkthrough_id_set = Option::<BTreeSet<WalkthroughId>>::None;
if let Some(meta) = existing_meta.as_ref() {
for diagram in &meta.diagrams {
existing_diagram_revs.insert(diagram.diagram_id.clone(), diagram.rev);
}
if let Some(ids) = meta.walkthrough_ids.as_ref() {
existing_walkthrough_id_set = Some(ids.iter().cloned().collect());
}
}
let read_walkthrough_rev = |path: &Path| -> Option<u64> {
let wt_str = fs::read_to_string(path).ok()?;
let wt_rev: WalkthroughRevJson = serde_json::from_str(&wt_str).ok()?;
Some(wt_rev.rev)
};
let mut meta = SessionMeta {
session_id: session.session_id().clone(),
active_diagram_id: session.active_diagram_id().cloned(),
active_walkthrough_id: session.active_walkthrough_id().cloned(),
walkthrough_ids: Some(Vec::new()),
diagrams: Vec::new(),
xrefs: Vec::new(),
selected_object_refs: session.selected_object_refs().iter().cloned().collect(),
};
let mut changed_diagram_artifacts = Vec::new();
let mut changed_walkthrough_artifacts = Vec::new();
let mut pending_diagram_ascii_exports = Vec::new();
let mut pending_walkthrough_ascii_exports = Vec::new();
for (diagram_id, diagram) in session.diagrams() {
let mmd_path = self.default_diagram_mmd_path(diagram_id);
let ascii_path = match self.diagram_ascii_path(&mmd_path) {
Ok(path) => path,
Err(err) => {
return self.rollback_session_artifacts_then(
&changed_diagram_artifacts,
&changed_walkthrough_artifacts,
err,
);
}
};
let meta_path = match self.diagram_meta_path(&mmd_path) {
Ok(path) => path,
Err(err) => {
return self.rollback_session_artifacts_then(
&changed_diagram_artifacts,
&changed_walkthrough_artifacts,
err,
);
}
};
let diagram_rev_unchanged = existing_diagram_revs
.get(diagram_id)
.copied()
.is_some_and(|rev| rev == diagram.rev())
&& mmd_path.is_file()
&& meta_path.is_file();
if !diagram_rev_unchanged {
let flow_edges = match diagram.ast() {
DiagramAst::Flowchart(ast) => ast
.edges()
.iter()
.map(|(edge_id, edge)| DiagramFlowEdgeMeta {
edge_id: edge_id.clone(),
from_node_id: edge.from_node_id().clone(),
to_node_id: edge.to_node_id().clone(),
label: edge.label().map(ToOwned::to_owned),
style: edge.style().map(ToOwned::to_owned),
})
.collect(),
DiagramAst::Sequence(_) => Vec::new(),
};
let sequence_messages = match diagram.ast() {
DiagramAst::Sequence(ast) => ast
.messages_in_order()
.into_iter()
.map(|msg| DiagramSequenceMessageMeta {
message_id: msg.message_id().clone(),
from_participant_id: msg.from_participant_id().clone(),
to_participant_id: msg.to_participant_id().clone(),
kind: msg.kind(),
text: msg.text().to_owned(),
})
.collect(),
DiagramAst::Flowchart(_) => Vec::new(),
};
let flow_node_notes = match diagram.ast() {
DiagramAst::Flowchart(ast) => ast
.nodes()
.iter()
.filter_map(|(node_id, node)| {
node.note().map(|note| (node_id.clone(), note.to_owned()))
})
.collect(),
DiagramAst::Sequence(_) => BTreeMap::new(),
};
let sequence_participant_notes = match diagram.ast() {
DiagramAst::Sequence(ast) => ast
.participants()
.iter()
.filter_map(|(participant_id, participant)| {
participant.note().map(|note| (participant_id.clone(), note.to_owned()))
})
.collect(),
DiagramAst::Flowchart(_) => BTreeMap::new(),
};
let diagram_meta = DiagramMeta {
diagram_id: diagram_id.clone(),
mmd_path: mmd_path.clone(),
stable_id_map: stable_id_map_from_ast(diagram.ast()),
xrefs: Vec::new(),
flow_edges,
sequence_messages,
flow_node_notes,
sequence_participant_notes,
};
match self.save_diagram_artifacts(diagram, &mmd_path, &meta_path, &diagram_meta) {
Ok(snapshot) => changed_diagram_artifacts.push(snapshot),
Err(err) => {
return self.rollback_session_artifacts_then(
&changed_diagram_artifacts,
&changed_walkthrough_artifacts,
err,
);
}
}
}
if !diagram_rev_unchanged || !ascii_path.is_file() {
pending_diagram_ascii_exports.push((mmd_path.clone(), diagram.clone()));
}
meta.diagrams.push(SessionMetaDiagram {
diagram_id: diagram_id.clone(),
name: diagram.name().to_owned(),
kind: diagram.kind(),
mmd_path,
rev: diagram.rev(),
});
}
for (xref_id, xref) in session.xrefs() {
meta.xrefs.push(SessionXRef {
xref_id: xref_id.clone(),
from: xref.from().clone(),
to: xref.to().clone(),
kind: xref.kind().to_owned(),
label: xref.label().map(ToOwned::to_owned),
status: xref.status(),
});
}
let mut walkthrough_ids = session.walkthroughs().keys().cloned().collect::<Vec<_>>();
walkthrough_ids.sort();
let current_walkthrough_id_set: BTreeSet<_> = walkthrough_ids.iter().cloned().collect();
let skip_walkthrough_gc = existing_walkthrough_id_set
.as_ref()
.is_some_and(|prev| prev == ¤t_walkthrough_id_set);
meta.walkthrough_ids = Some(walkthrough_ids.clone());
for walkthrough_id in walkthrough_ids {
let walkthrough = session
.walkthroughs()
.get(&walkthrough_id)
.expect("walkthrough id listed in walkthrough_ids");
let json_path = self.walkthrough_json_path(&walkthrough_id);
let ascii_path = self.walkthrough_ascii_path(&walkthrough_id);
let rev_matches = json_path.is_file()
&& read_walkthrough_rev(&json_path).is_some_and(|rev| rev == walkthrough.rev());
if !rev_matches {
let snapshot =
match self.snapshot_walkthrough_artifacts(walkthrough.walkthrough_id()) {
Ok(snapshot) => snapshot,
Err(err) => {
return self.rollback_session_artifacts_then(
&changed_diagram_artifacts,
&changed_walkthrough_artifacts,
err,
);
}
};
changed_walkthrough_artifacts.push(snapshot);
if let Err(err) = self.save_walkthrough_json(walkthrough) {
return self.rollback_session_artifacts_then(
&changed_diagram_artifacts,
&changed_walkthrough_artifacts,
err,
);
}
pending_walkthrough_ascii_exports.push(walkthrough.clone());
} else if !ascii_path.is_file() {
pending_walkthrough_ascii_exports.push(walkthrough.clone());
}
}
if let Err(err) = self.save_meta(&meta) {
return self.rollback_session_artifacts_then(
&changed_diagram_artifacts,
&changed_walkthrough_artifacts,
err,
);
}
for (mmd_path, diagram) in pending_diagram_ascii_exports {
self.schedule_diagram_ascii_export(&mmd_path, &diagram)?;
}
for walkthrough in pending_walkthrough_ascii_exports {
self.schedule_walkthrough_ascii_export(&walkthrough)?;
}
if !skip_walkthrough_gc {
if let Some(walkthrough_ids) = meta.walkthrough_ids.as_deref() {
self.garbage_collect_walkthrough_files(walkthrough_ids)?;
}
}
Ok(())
}
fn garbage_collect_walkthrough_files(
&self,
walkthrough_ids: &[WalkthroughId],
) -> Result<(), StoreError> {
let mut keep_stems = std::collections::BTreeSet::<String>::new();
for id in walkthrough_ids {
keep_stems.insert(id.to_string());
keep_stems.insert(encode_persisted_id_segment(id.as_str()));
}
let walkthroughs_dir = self.root.join("walkthroughs");
let walkthroughs_metadata = match fs::symlink_metadata(&walkthroughs_dir) {
Ok(metadata) => metadata,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(StoreError::Io { path: walkthroughs_dir, source });
}
};
if walkthroughs_metadata.file_type().is_symlink() {
return Err(StoreError::SymlinkRefused { path: walkthroughs_dir });
}
if !walkthroughs_metadata.is_dir() {
return Err(StoreError::Io {
path: walkthroughs_dir,
source: io::Error::new(io::ErrorKind::AlreadyExists, "expected directory"),
});
}
let canonical_walkthroughs_dir = fs::canonicalize(&walkthroughs_dir)
.map_err(|source| StoreError::Io { path: walkthroughs_dir.clone(), source })?;
let entries = match fs::read_dir(&walkthroughs_dir) {
Ok(entries) => entries,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(StoreError::Io { path: walkthroughs_dir, source });
}
};
for entry in entries {
let entry = entry
.map_err(|source| StoreError::Io { path: walkthroughs_dir.clone(), source })?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|source| StoreError::Io { path: path.clone(), source })?;
if !file_type.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(walkthrough_id) =
file_name.strip_suffix(".wt.json").or_else(|| file_name.strip_suffix(".ascii.txt"))
else {
continue;
};
if keep_stems.contains(walkthrough_id) {
continue;
}
let canonical_path = fs::canonicalize(&path)
.map_err(|source| StoreError::Io { path: path.clone(), source })?;
if !canonical_path.starts_with(&canonical_walkthroughs_dir) {
return Err(StoreError::PathOutsideSession {
session_dir: canonical_walkthroughs_dir.clone(),
path: canonical_path,
});
}
ascii_exports().cancel(&path);
match fs::remove_file(&path) {
Ok(()) => {}
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(source) => {
return Err(StoreError::Io { path, source });
}
}
}
Ok(())
}
pub fn load_session(&self) -> Result<Session, StoreError> {
let meta = self.load_meta()?;
let mut session = Session::new(meta.session_id);
session.set_active_diagram_id(meta.active_diagram_id);
session.set_active_walkthrough_id(meta.active_walkthrough_id);
session.set_selected_object_refs(meta.selected_object_refs.into_iter().collect());
let walkthrough_ids = meta.walkthrough_ids.clone();
for diagram_meta in meta.diagrams {
let diagram_id = diagram_meta.diagram_id;
let mmd_path = diagram_meta.mmd_path;
let mmd = fs::read_to_string(&mmd_path)
.map_err(|source| StoreError::Io { path: mmd_path.clone(), source })?;
let sidecar = match self.load_diagram_meta(&mmd_path) {
Ok(sidecar) => Some(sidecar),
Err(StoreError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => {
None
}
Err(err) => return Err(err),
};
let mut ast = match diagram_meta.kind {
DiagramKind::Sequence => {
DiagramAst::Sequence(parse_sequence_diagram(&mmd).map_err(|source| {
StoreError::MermaidSequenceParse {
diagram_id: diagram_id.clone(),
path: mmd_path.clone(),
source: Box::new(source),
}
})?)
}
DiagramKind::Flowchart => {
DiagramAst::Flowchart(parse_flowchart(&mmd).map_err(|source| {
StoreError::MermaidFlowchartParse {
diagram_id: diagram_id.clone(),
path: mmd_path.clone(),
source: Box::new(source),
}
})?)
}
};
if let Some(sidecar) = sidecar.as_ref() {
match &mut ast {
DiagramAst::Flowchart(flow_ast) => {
reconcile_flowchart_nodes(flow_ast, sidecar);
reconcile_flowchart_edges(flow_ast, sidecar);
reconcile_flowchart_notes(flow_ast, sidecar);
}
DiagramAst::Sequence(seq_ast) => {
reconcile_sequence_participants(seq_ast, sidecar);
reconcile_sequence_messages(seq_ast, sidecar);
reconcile_sequence_participant_notes(seq_ast, sidecar);
}
}
}
let mut diagram = Diagram::new(diagram_id.clone(), diagram_meta.name, ast);
diagram.set_rev(diagram_meta.rev);
session.diagrams_mut().insert(diagram_id, diagram);
}
for xref_meta in meta.xrefs {
let mut xref =
XRef::new(xref_meta.from, xref_meta.to, xref_meta.kind, xref_meta.status);
xref.set_label(xref_meta.label);
session.xrefs_mut().insert(xref_meta.xref_id, xref);
}
refresh_xref_statuses(&mut session);
match walkthrough_ids {
Some(walkthrough_ids) => {
for walkthrough_id in walkthrough_ids {
let walkthrough = self.load_walkthrough(&walkthrough_id)?;
session.walkthroughs_mut().insert(walkthrough_id, walkthrough);
}
}
None => {
let walkthroughs_dir = self.root.join("walkthroughs");
match fs::read_dir(&walkthroughs_dir) {
Ok(entries) => {
let mut wt_paths = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_file())
.filter(|path| path.to_string_lossy().ends_with(".wt.json"))
.collect::<Vec<_>>();
wt_paths.sort();
for wt_path in wt_paths {
let wt_str = fs::read_to_string(&wt_path).map_err(|source| {
StoreError::Io { path: wt_path.clone(), source }
})?;
let wt_json: WalkthroughJson =
serde_json::from_str(&wt_str).map_err(|source| {
StoreError::Json { path: wt_path.clone(), source }
})?;
let walkthrough = walkthrough_from_json(wt_json)?;
session
.walkthroughs_mut()
.insert(walkthrough.walkthrough_id().clone(), walkthrough);
}
}
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(source) => {
return Err(StoreError::Io { path: walkthroughs_dir, source });
}
}
}
}
Ok(session)
}
pub fn load_meta(&self) -> Result<SessionMeta, StoreError> {
let meta_path = self.meta_path();
let (meta_path, meta_str) = match fs::read_to_string(&meta_path) {
Ok(meta_str) => (meta_path, meta_str),
Err(source) if source.kind() == io::ErrorKind::NotFound => {
let legacy_path = self.legacy_meta_path();
match fs::read_to_string(&legacy_path) {
Ok(meta_str) => (legacy_path, meta_str),
Err(legacy_source) if legacy_source.kind() == io::ErrorKind::NotFound => {
return Err(StoreError::Io { path: meta_path, source });
}
Err(legacy_source) => {
return Err(StoreError::Io { path: legacy_path, source: legacy_source });
}
}
}
Err(source) => {
return Err(StoreError::Io { path: meta_path.clone(), source });
}
};
let meta_json: SessionMetaJson = serde_json::from_str(&meta_str)
.map_err(|source| StoreError::Json { path: meta_path.clone(), source })?;
session_meta_from_json(self.root(), meta_json)
}
pub fn save_meta(&self, meta: &SessionMeta) -> Result<(), StoreError> {
fs::create_dir_all(self.root())
.map_err(|source| StoreError::Io { path: self.root.clone(), source })?;
let meta_path = self.meta_path();
let meta_json = session_meta_to_json(self.root(), meta)?;
let meta_str = serde_json::to_string_pretty(&meta_json)
.map_err(|source| StoreError::Json { path: meta_path.clone(), source })?;
write_atomic_in_session(
self.root(),
&meta_path,
format!("{meta_str}\n").as_bytes(),
self.durability,
)?;
let legacy_path = self.legacy_meta_path();
if legacy_path != meta_path {
match fs::remove_file(&legacy_path) {
Ok(()) => {}
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(_source) => {}
}
}
Ok(())
}
pub fn save_selected_object_refs(&self, session: &Session) -> Result<(), StoreError> {
let _guard = self.lock_session_write()?;
match self.load_meta() {
Ok(mut meta) => {
meta.selected_object_refs =
session.selected_object_refs().iter().cloned().collect();
self.save_meta(&meta)?;
Ok(())
}
Err(StoreError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => {
self.save_session_locked(session)
}
Err(err) => Err(err),
}
}
pub fn save_active_diagram_id(&self, session: &Session) -> Result<(), StoreError> {
let _guard = self.lock_session_write()?;
match self.load_meta() {
Ok(mut meta) => {
meta.active_diagram_id = session.active_diagram_id().cloned();
self.save_meta(&meta)?;
Ok(())
}
Err(StoreError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => {
self.save_session_locked(session)
}
Err(err) => Err(err),
}
}
pub fn load_walkthrough(
&self,
walkthrough_id: &WalkthroughId,
) -> Result<Walkthrough, StoreError> {
let wt_path = self.walkthrough_json_path(walkthrough_id);
let (wt_path, wt_str) = match fs::read_to_string(&wt_path) {
Ok(wt_str) => (wt_path, wt_str),
Err(source) if source.kind() == io::ErrorKind::NotFound => {
let legacy_path = self.legacy_walkthrough_json_path(walkthrough_id)?;
let wt_str = fs::read_to_string(&legacy_path)
.map_err(|source| StoreError::Io { path: legacy_path.clone(), source })?;
(legacy_path, wt_str)
}
Err(source) => {
return Err(StoreError::Io { path: wt_path.clone(), source });
}
};
let wt_json: WalkthroughJson = serde_json::from_str(&wt_str)
.map_err(|source| StoreError::Json { path: wt_path.clone(), source })?;
walkthrough_from_json(wt_json)
}
pub fn save_walkthrough(&self, walkthrough: &Walkthrough) -> Result<(), StoreError> {
self.save_walkthrough_json(walkthrough)?;
self.schedule_walkthrough_ascii_export(walkthrough)?;
Ok(())
}
fn save_walkthrough_json(&self, walkthrough: &Walkthrough) -> Result<(), StoreError> {
let wt_path = self.walkthrough_json_path(walkthrough.walkthrough_id());
let wt_json = walkthrough_to_json(walkthrough);
let wt_str = serde_json::to_string_pretty(&wt_json)
.map_err(|source| StoreError::Json { path: wt_path.clone(), source })?;
write_atomic_in_session(
self.root(),
&wt_path,
format!("{wt_str}\n").as_bytes(),
self.durability,
)?;
Ok(())
}
fn schedule_diagram_ascii_export(
&self,
mmd_path: &Path,
diagram: &Diagram,
) -> Result<(), StoreError> {
let text_path = self.diagram_ascii_path(mmd_path)?;
ascii_exports().schedule(AsciiExportTask::Diagram {
session_dir: self.root.clone(),
mmd_path: mmd_path.to_path_buf(),
text_path,
durability: self.durability,
ast: diagram.ast().clone(),
});
Ok(())
}
fn schedule_walkthrough_ascii_export(
&self,
walkthrough: &Walkthrough,
) -> Result<(), StoreError> {
let json_path = self.walkthrough_json_path(walkthrough.walkthrough_id());
let text_path = self.walkthrough_ascii_path(walkthrough.walkthrough_id());
ascii_exports().schedule(AsciiExportTask::Walkthrough {
session_dir: self.root.clone(),
json_path,
text_path,
durability: self.durability,
walkthrough: walkthrough.clone(),
});
Ok(())
}
pub fn load_diagram_meta(&self, mmd_path: &Path) -> Result<DiagramMeta, StoreError> {
let meta_path = self.diagram_meta_path(mmd_path)?;
let meta_str = fs::read_to_string(&meta_path)
.map_err(|source| StoreError::Io { path: meta_path.clone(), source })?;
let meta_json: DiagramMetaJson = serde_json::from_str(&meta_str)
.map_err(|source| StoreError::Json { path: meta_path.clone(), source })?;
diagram_meta_from_json(self.root(), meta_json)
}
pub fn save_diagram_meta(&self, meta: &DiagramMeta) -> Result<(), StoreError> {
let meta_path = self.diagram_meta_path(&meta.mmd_path)?;
let meta_json = diagram_meta_to_json(self.root(), meta)?;
let meta_str = serde_json::to_string_pretty(&meta_json)
.map_err(|source| StoreError::Json { path: meta_path.clone(), source })?;
write_atomic_in_session(
self.root(),
&meta_path,
format!("{meta_str}\n").as_bytes(),
self.durability,
)?;
Ok(())
}
}
include!("session_folder/helpers.rs");
#[cfg(test)]
mod tests;