use std::fs;
use std::path::PathBuf;
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::db::{DatabaseObject, store};
use crate::ui::filter_bar::FilterSpec;
const FILE_NAME: &str = "workspace.json";
pub const VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WorkspaceState {
pub version: u32,
pub active: usize,
pub sessions: Vec<SessionState>,
}
impl Default for WorkspaceState {
fn default() -> Self {
Self {
version: VERSION,
active: 0,
sessions: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SessionState {
pub connection: Uuid,
pub database: Option<String>,
pub active: usize,
pub panels: Vec<PanelState>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "panel", rename_all = "snake_case")]
pub enum PanelState {
Query {
title: String,
sql: String,
#[serde(default)]
file: Option<PathBuf>,
},
Table {
object: DatabaseObject,
#[serde(default)]
filters: Vec<FilterSpec>,
},
Schema {
object: DatabaseObject,
},
Console,
Processes,
Variables,
Digest,
Maintenance,
#[serde(other)]
Unknown,
}
fn workspace_file() -> Result<PathBuf> {
Ok(store::config_dir()?.join(FILE_NAME))
}
pub fn load() -> Result<WorkspaceState> {
let path = workspace_file()?;
if !path.exists() {
return Ok(WorkspaceState::default());
}
let contents =
fs::read_to_string(&path).with_context(|| format!("could not read {}", path.display()))?;
serde_json::from_str(&contents).with_context(|| format!("could not parse {}", path.display()))
}
#[cfg(not(test))]
pub fn save(state: &WorkspaceState) -> Result<()> {
let path = workspace_file()?;
let dir = path.parent().context("no config directory")?;
fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?;
let contents = serde_json::to_string_pretty(state)?;
let temporary = dir.join(format!("{FILE_NAME}.tmp"));
store::write_restricted(&temporary, &contents)
.with_context(|| format!("could not write {}", temporary.display()))?;
if let Err(error) = fs::rename(&temporary, &path) {
store::write_restricted(&path, &contents)
.with_context(|| format!("could not write {}: {error:#}", path.display()))?;
let _ = fs::remove_file(&temporary);
}
Ok(())
}
#[cfg(test)]
pub fn save(_state: &WorkspaceState) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ObjectKind;
use crate::ui::filter_bar::Operator;
fn scratch_dir() -> PathBuf {
let path = std::env::temp_dir().join(format!("zippa-workspace-{}", Uuid::new_v4()));
fs::create_dir_all(&path).expect("could not create the scratch directory");
path
}
fn object() -> DatabaseObject {
DatabaseObject {
schema: Some("public".into()),
name: "items".into(),
kind: ObjectKind::Table,
}
}
#[test]
fn a_full_state_survives_a_round_trip_through_json() {
let state = WorkspaceState {
active: 1,
sessions: vec![SessionState {
connection: Uuid::new_v4(),
database: Some("app".into()),
active: 2,
panels: vec![
PanelState::Query {
title: "Query 1".into(),
sql: "select 1".into(),
file: None,
},
PanelState::Query {
title: "report.sql".into(),
sql: "select 2".into(),
file: Some(PathBuf::from("/tmp/report.sql")),
},
PanelState::Table {
object: object(),
filters: vec![FilterSpec {
column: "status".into(),
operator: Operator::Equals,
value: "active".into(),
}],
},
PanelState::Schema { object: object() },
],
}],
..WorkspaceState::default()
};
let written = serde_json::to_string(&state).expect("the state should serialize");
let read: WorkspaceState = serde_json::from_str(&written).expect("the state should parse");
assert_eq!(read, state);
assert_eq!(read.version, VERSION);
}
#[test]
fn an_empty_file_object_loads_as_the_defaults() {
let dir = scratch_dir();
store::set_config_dir_for_test(dir.clone());
fs::write(dir.join(FILE_NAME), "{}").expect("could not write the file");
let state = load().expect("an empty file should load");
assert_eq!(state, WorkspaceState::default());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_unknown_field_is_ignored() {
let dir = scratch_dir();
store::set_config_dir_for_test(dir.clone());
fs::write(
dir.join(FILE_NAME),
r#"{"version": 99, "future": true, "sessions": []}"#,
)
.expect("could not write the file");
let state = load().expect("an unknown field should not stop the load");
assert_eq!(state.version, 99);
assert!(state.sessions.is_empty());
fs::write(
dir.join(FILE_NAME),
r#"{"sessions": [{"connection": "00000000-0000-0000-0000-000000000000", "unknown": 1}]}"#,
)
.expect("could not write the file");
assert!(load().is_ok());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_panel_kind_a_newer_build_wrote_is_skipped() {
let dir = scratch_dir();
store::set_config_dir_for_test(dir.clone());
fs::write(
dir.join(FILE_NAME),
r#"{"sessions": [{"panels": [{"panel": "graph"}]}]}"#,
)
.expect("could not write the file");
let state = load().expect("a newer panel kind should not stop the load");
assert_eq!(state.sessions[0].panels, [PanelState::Unknown]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_garbage_file_is_an_error_the_caller_can_fall_back_from() {
let dir = scratch_dir();
store::set_config_dir_for_test(dir.clone());
fs::write(dir.join(FILE_NAME), "not json at all").expect("could not write the file");
assert!(load().is_err());
let _ = fs::remove_dir_all(&dir);
}
}