use gantz_ca::{CaHash, CommitAddr, registry::MergeResult};
use gantz_core::node::{self, GetNode, graph::Graph};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::collections::{HashMap, HashSet};
pub const FILE_EXTENSION: &str = "gantz";
#[derive(Debug)]
pub enum ParseExportError {
Utf8(std::str::Utf8Error),
Format(crate::format::FormatError),
}
impl std::fmt::Display for ParseExportError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Utf8(e) => write!(f, "invalid UTF-8: {e}"),
Self::Format(e) => write!(f, "failed to parse .gantz text: {e}"),
}
}
}
impl std::error::Error for ParseExportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Utf8(e) => Some(e),
Self::Format(e) => Some(e),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Export<G> {
pub registry: gantz_ca::Registry<G>,
#[serde(default, serialize_with = "gantz_ca::serde_sorted::serialize_map")]
pub views: HashMap<CommitAddr, crate::SceneView>,
#[serde(default)]
pub demos: HashMap<String, String>,
}
pub fn export_with<G>(
registry: gantz_ca::Registry<G>,
all_views: &HashMap<CommitAddr, crate::SceneView>,
all_demos: &HashMap<String, String>,
) -> Export<G>
where
G: Clone,
{
let commits = registry.commits();
let views = all_views
.iter()
.filter(|(ca, _)| commits.contains_key(ca))
.map(|(&ca, v)| (ca, v.clone()))
.collect();
let names = registry.names();
let demos = all_demos
.iter()
.filter(|(name, _)| names.contains_key(name.as_str()))
.map(|(name, demo)| (name.clone(), demo.clone()))
.collect();
Export {
registry,
views,
demos,
}
}
pub fn parse_export<N>(bytes: &[u8]) -> Result<Export<Graph<N>>, ParseExportError>
where
N: Serialize + DeserializeOwned + CaHash + gantz_format::NodeSugar + 'static,
{
parse_export_at(bytes, now())
}
pub fn parse_export_at<N>(
bytes: &[u8],
now: gantz_ca::Timestamp,
) -> Result<Export<Graph<N>>, ParseExportError>
where
N: Serialize + DeserializeOwned + CaHash + gantz_format::NodeSugar + 'static,
{
let text = std::str::from_utf8(bytes).map_err(ParseExportError::Utf8)?;
crate::format::from_str(text, now).map_err(ParseExportError::Format)
}
pub fn parse_export_seeded_at<N>(
bytes: &[u8],
now: gantz_ca::Timestamp,
seed: &std::collections::BTreeMap<String, gantz_ca::CommitAddr>,
) -> Result<Export<Graph<N>>, ParseExportError>
where
N: Serialize + DeserializeOwned + CaHash + gantz_format::NodeSugar + 'static,
{
let text = std::str::from_utf8(bytes).map_err(ParseExportError::Utf8)?;
crate::format::from_str_seeded(text, now, seed).map_err(ParseExportError::Format)
}
fn now() -> gantz_ca::Timestamp {
web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.unwrap_or_default()
}
pub fn unique_root_name<N>(get_node: GetNode, export: &Export<Graph<N>>) -> Option<String>
where
N: gantz_core::Node,
{
let mut roots = gantz_core::reg::root_names(get_node, &export.registry);
(roots.len() == 1).then(|| roots.pop().unwrap())
}
pub fn export_heads_sexpr<N>(
get_node: GetNode,
registry: &gantz_ca::Registry<Graph<N>>,
all_views: &HashMap<CommitAddr, crate::SceneView>,
all_demos: &HashMap<String, String>,
heads: impl IntoIterator<Item = impl std::borrow::Borrow<gantz_ca::Head>>,
) -> Result<String, crate::format::FormatError>
where
N: Serialize + DeserializeOwned + gantz_core::Node + Clone + gantz_format::NodeSugar,
{
let export_registry = gantz_core::reg::export_heads(get_node, registry, heads);
let export = export_with(export_registry, all_views, all_demos);
crate::format::to_string(&export)
}
pub fn export_heads_sexpr_named<N>(
get_node: GetNode,
registry: &gantz_ca::Registry<Graph<N>>,
all_views: &HashMap<CommitAddr, crate::SceneView>,
all_demos: &HashMap<String, String>,
heads: impl IntoIterator<Item = impl std::borrow::Borrow<gantz_ca::Head>>,
) -> Result<String, crate::format::FormatError>
where
N: Serialize + DeserializeOwned + gantz_core::Node + Clone + gantz_format::NodeSugar,
{
let export_registry = gantz_core::reg::export_heads(get_node, registry, heads);
let export = export_with(export_registry, all_views, all_demos);
crate::format::to_string_named(&export)
}
pub fn export_names_sexpr_named<N>(
registry: &gantz_ca::Registry<Graph<N>>,
all_views: &HashMap<CommitAddr, crate::SceneView>,
all_demos: &HashMap<String, String>,
names: impl IntoIterator<Item = impl AsRef<str>>,
) -> Result<String, crate::format::FormatError>
where
N: Serialize + DeserializeOwned + gantz_core::Node + Clone + gantz_format::NodeSugar,
{
let requested: std::collections::HashSet<String> = names
.into_iter()
.map(|name| name.as_ref().to_string())
.collect();
let required: std::collections::HashSet<gantz_ca::CommitAddr> = requested
.iter()
.filter_map(|name| registry.names().get(name).copied())
.collect();
let mut export_registry = registry.export(&required);
let extra: Vec<String> = export_registry
.names()
.keys()
.filter(|name| !requested.contains(*name))
.cloned()
.collect();
for name in extra {
export_registry.remove_name(&name);
export_registry.set_description(name, String::new());
}
let export = export_with(export_registry, all_views, all_demos);
crate::format::to_string_named(&export)
}
pub fn merge_with<G>(
registry: &mut gantz_ca::Registry<G>,
views: &mut HashMap<CommitAddr, crate::SceneView>,
demos: &mut HashMap<String, String>,
export: Export<G>,
) -> MergeResult {
let result = registry.merge(export.registry);
for (ca, v) in export.views {
views.entry(ca).or_insert(v);
}
for (name, d) in export.demos {
demos.entry(name).or_insert(d);
}
result
}
pub fn default_filename(head: &gantz_ca::Head) -> String {
match head {
gantz_ca::Head::Branch(name) => format!("{name}.{FILE_EXTENSION}"),
gantz_ca::Head::Commit(ca) => format!("{}.{FILE_EXTENSION}", ca.display_short()),
}
}
pub fn is_gantz_path(path: &std::path::Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case(FILE_EXTENSION))
.unwrap_or(false)
}
pub fn is_maybe_gantz(path: Option<&std::path::Path>) -> bool {
path.map(is_gantz_path).unwrap_or(true)
}
pub fn read_dropped_file(file: &egui::DroppedFile) -> Option<Vec<u8>> {
if let Some(ref bytes) = file.bytes {
return Some(bytes.to_vec());
}
if let Some(ref path) = file.path {
return std::fs::read(path).ok();
}
None
}
const CLIPBOARD_NAME: &str = "clipboard";
#[derive(Debug)]
pub enum ParseCopiedError {
Format(crate::format::FormatError),
NotClipboard,
}
impl std::fmt::Display for ParseCopiedError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Format(e) => write!(f, "failed to parse .gantz text: {e}"),
Self::NotClipboard => write!(f, "document carries no `{CLIPBOARD_NAME}` graph"),
}
}
}
impl std::error::Error for ParseCopiedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Format(e) => Some(e),
Self::NotClipboard => None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Copied<N> {
pub export: Export<Graph<N>>,
pub graph: Graph<N>,
pub positions: egui_graph::Layout,
}
pub fn copy<N>(
registry: &gantz_ca::Registry<Graph<N>>,
all_views: &HashMap<CommitAddr, crate::SceneView>,
graph: &Graph<N>,
selected: &HashSet<node::graph::NodeIx>,
layout: &egui_graph::Layout,
) -> Copied<N>
where
N: Clone + gantz_core::Node,
{
let subgraph = gantz_core::graph::extract_subgraph(graph, selected);
let mut positions = egui_graph::Layout::default();
let sorted: std::collections::BTreeSet<_> = selected.iter().copied().collect();
for (old_ix, new_ix) in sorted.iter().zip(subgraph.node_indices()) {
let old_id = egui_graph::NodeId(old_ix.index() as u64);
let new_id = egui_graph::NodeId(new_ix.index() as u64);
if let Some(&pos) = layout.get(&old_id) {
positions.insert(new_id, pos);
}
}
let mut required_commits = HashSet::new();
let mut stack: Vec<CommitAddr> = subgraph
.node_weights()
.flat_map(|n| n.required_addrs())
.map(CommitAddr::from)
.filter(|ca| registry.commits().contains_key(ca))
.collect();
while let Some(commit_ca) = stack.pop() {
if !required_commits.insert(commit_ca) {
continue;
}
if let Some(nested) = registry.commit_graph_ref(&commit_ca) {
for ca in nested.node_weights().flat_map(|n| n.required_addrs()) {
let dep = CommitAddr::from(ca);
if registry.commits().contains_key(&dep) {
stack.push(dep);
}
}
}
}
let export_registry = registry.export(&required_commits);
let export = export_with(export_registry, all_views, &HashMap::new());
Copied {
export,
graph: subgraph,
positions,
}
}
pub fn paste<N>(
registry: &mut gantz_ca::Registry<Graph<N>>,
views: &mut HashMap<CommitAddr, crate::SceneView>,
demos: &mut HashMap<String, String>,
target_graph: &mut Graph<N>,
target_layout: &mut egui_graph::Layout,
copied: &Copied<N>,
offset: egui::Vec2,
) -> Vec<node::graph::NodeIx>
where
N: Clone,
{
merge_with(registry, views, demos, copied.export.clone());
let new_indices = gantz_core::graph::add_subgraph(target_graph, &copied.graph);
for (sub_ix, &target_ix) in copied.graph.node_indices().zip(new_indices.iter()) {
let sub_id = egui_graph::NodeId(sub_ix.index() as u64);
let target_id = egui_graph::NodeId(target_ix.index() as u64);
if let Some(&pos) = copied.positions.get(&sub_id) {
target_layout.insert(target_id, pos + offset);
}
}
new_indices
}
pub fn copied_to_string<N>(copied: &Copied<N>) -> Result<String, crate::format::FormatError>
where
N: Serialize + DeserializeOwned + CaHash + Clone + gantz_format::NodeSugar + 'static,
{
let mut registry = copied.export.registry.clone();
let g_addr = registry.add_graph(copied.graph.clone());
let commit_ca = registry.add_commit(gantz_ca::Commit::new(
std::time::Duration::ZERO,
None,
g_addr,
));
registry.insert_name(CLIPBOARD_NAME.to_string(), commit_ca);
let mut views = copied.export.views.clone();
views.insert(
commit_ca,
crate::SceneView {
camera: crate::Camera::default(),
layout: copied.positions.clone(),
},
);
let export = Export {
registry,
views,
demos: copied.export.demos.clone(),
};
crate::format::to_string(&export)
}
pub fn copied_from_str<N>(text: &str) -> Result<Copied<N>, ParseCopiedError>
where
N: Serialize + DeserializeOwned + CaHash + Clone + gantz_format::NodeSugar + 'static,
{
let mut export = crate::format::from_str::<N>(text, now()).map_err(ParseCopiedError::Format)?;
let clip_ca = export
.registry
.names()
.get(CLIPBOARD_NAME)
.copied()
.ok_or(ParseCopiedError::NotClipboard)?;
let graph = export
.registry
.commit_graph_ref(&clip_ca)
.cloned()
.ok_or(ParseCopiedError::NotClipboard)?;
let positions = export
.views
.get(&clip_ca)
.map(|view| view.layout.clone())
.unwrap_or_default();
let deps: HashSet<CommitAddr> = export
.registry
.commits()
.keys()
.copied()
.filter(|&ca| ca != clip_ca)
.collect();
let registry = export.registry.export(&deps);
export.views.remove(&clip_ca);
Ok(Copied {
export: Export {
registry,
views: export.views,
demos: export.demos,
},
graph,
positions,
})
}
#[cfg(test)]
mod tests {
use super::*;
use gantz_ca::{Commit, ContentAddr};
use std::{collections::BTreeMap, time::Duration};
fn graph_addr(n: u8) -> gantz_ca::GraphAddr {
gantz_ca::GraphAddr::from(ContentAddr::from([n; 32]))
}
fn commit_addr_raw(n: u8) -> CommitAddr {
CommitAddr::from(ContentAddr::from([n; 32]))
}
fn test_export() -> Export<String> {
let ga = graph_addr(1);
let ca = commit_addr_raw(10);
let commit = Commit::new(Duration::from_secs(1), None, ga);
let registry = gantz_ca::Registry::new(
HashMap::from([(ga, "graph_a".to_string())]),
HashMap::from([(ca, commit)]),
BTreeMap::from([("alpha".to_string(), ca)]),
);
Export {
registry,
views: HashMap::new(),
demos: HashMap::new(),
}
}
#[test]
fn export_merge_recovers_data() {
let export = test_export();
let mut target = gantz_ca::Registry::<String>::default();
let mut views = HashMap::new();
let mut demos = HashMap::new();
let result = merge_with(&mut target, &mut views, &mut demos, export);
assert_eq!(result.names_added, vec!["alpha".to_string()]);
assert!(result.names_replaced.is_empty());
let ca = commit_addr_raw(10);
assert!(target.commits().contains_key(&ca));
assert_eq!(target.names().get("alpha"), Some(&ca));
}
#[test]
fn export_with_filters_views() {
let ga = graph_addr(1);
let ca = commit_addr_raw(10);
let cb = commit_addr_raw(20);
let commit = Commit::new(Duration::from_secs(1), None, ga);
let registry = gantz_ca::Registry::new(
HashMap::from([(ga, "g".to_string())]),
HashMap::from([(ca, commit)]),
BTreeMap::new(),
);
let mut all_views = HashMap::new();
all_views.insert(ca, crate::SceneView::default());
all_views.insert(cb, crate::SceneView::default()); let export = export_with(registry, &all_views, &HashMap::new());
assert!(export.views.contains_key(&ca));
assert!(!export.views.contains_key(&cb));
}
#[test]
fn export_with_filters_demos() {
let ga = graph_addr(1);
let ca = commit_addr_raw(10);
let commit = Commit::new(Duration::from_secs(1), None, ga);
let registry = gantz_ca::Registry::new(
HashMap::from([(ga, "g".to_string())]),
HashMap::from([(ca, commit)]),
BTreeMap::from([("alpha".to_string(), ca)]),
);
let all_demos = HashMap::from([
("alpha".to_string(), "demo-alpha".to_string()),
("beta".to_string(), "demo-beta".to_string()),
]);
let export = export_with(registry, &HashMap::new(), &all_demos);
assert_eq!(
export.demos.get("alpha").map(String::as_str),
Some("demo-alpha")
);
assert!(!export.demos.contains_key("beta"));
}
#[test]
fn merge_with_keeps_existing_views() {
let ga = graph_addr(1);
let ca = commit_addr_raw(10);
let commit = Commit::new(Duration::from_secs(1), None, ga);
let mut registry = gantz_ca::Registry::new(
HashMap::from([(ga, "g".to_string())]),
HashMap::from([(ca, commit.clone())]),
BTreeMap::new(),
);
let mut existing_view = crate::SceneView::default();
existing_view
.layout
.insert(egui_graph::NodeId(0), Default::default());
let mut views = HashMap::from([(ca, existing_view)]);
let mut demos = HashMap::new();
let export = Export {
registry: gantz_ca::Registry::new(
HashMap::from([(ga, "g".to_string())]),
HashMap::from([(ca, commit)]),
BTreeMap::new(),
),
views: HashMap::from([(ca, crate::SceneView::default())]),
demos: HashMap::new(),
};
merge_with(&mut registry, &mut views, &mut demos, export);
assert_eq!(views[&ca].layout.len(), 1);
}
#[test]
fn is_gantz_path_matches_extension() {
use std::path::Path;
assert!(is_gantz_path(Path::new("foo.gantz")));
assert!(is_gantz_path(Path::new("/tmp/bar.gantz")));
assert!(is_gantz_path(Path::new("x.GANTZ")));
assert!(!is_gantz_path(Path::new("foo.txt")));
assert!(!is_gantz_path(Path::new("foo")));
assert!(!is_gantz_path(Path::new("gantz")));
}
}