use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::collection::Collection;
use crate::env_panel::EnvSource;
use crate::environment::{Environment, PendingSecret, parse_vars_pending};
use crate::git_remote::GitOrigin;
use crate::hurl::HurlEntry;
use crate::i18n::Language;
use crate::remote_flow::WorkspaceGitOrigin;
use crate::request::RequestView;
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct PersistedEnv {
pub name: String,
#[serde(default)]
pub vars: Vec<PersistedVar>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub git_origin: Option<GitOrigin>,
}
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct PersistedVar {
pub key: String,
pub raw: String,
#[serde(default)]
pub user_added: bool,
}
impl PersistedEnv {
pub fn from_environment(env: &Environment) -> Self {
Self {
name: env.name.clone(),
vars: env
.vars
.iter()
.map(|v| PersistedVar {
key: v.key.clone(),
raw: v.raw.clone(),
user_added: v.user_added,
})
.collect(),
path: env.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
git_origin: env.git_origin.clone(),
}
}
pub fn restore(&self) -> (Environment, Vec<PendingSecret>) {
let content = self
.vars
.iter()
.map(|v| format!("{}={}", v.key, crate::environment::flatten_value(&v.raw)))
.collect::<Vec<_>>()
.join("\n");
let (mut env, pending) = parse_vars_pending(self.name.clone(), &content);
for pv in &self.vars {
if pv.user_added
&& let Some(var) = env.vars.iter_mut().find(|x| x.key == pv.key)
{
var.user_added = true;
}
}
env.path = self
.path
.clone()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.map(crate::shared_utils::file_path);
env.git_origin = self.git_origin.clone();
(env, pending)
}
}
#[derive(Serialize, Deserialize, Default)]
pub struct PersistedTab {
pub name: String,
#[serde(default)]
pub entries: Vec<HurlEntry>,
#[serde(default)]
pub selected_entry: usize,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub git_origin: Option<GitOrigin>,
#[serde(default)]
pub linked_env_index: Option<usize>,
#[serde(default)]
pub workspace_root: Option<String>,
#[serde(default)]
pub workspace_filter_hurl_json: Option<bool>,
#[serde(default)]
pub workspace_downloaded_from_git: bool,
#[serde(default)]
pub workspace_git_origin: Option<WorkspaceGitOrigin>,
#[serde(default)]
pub workspace_expanded_paths: Vec<String>,
#[serde(default)]
pub workspace_selected_path: Option<String>,
}
pub struct PendingWorkspaceReload {
pub tab_name: String,
pub origin: WorkspaceGitOrigin,
pub relative_selected_path: Option<String>,
}
fn rel_slashes(rel: &std::path::Path) -> String {
rel.components()
.filter_map(|comp| comp.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
}
impl PersistedTab {
pub fn from_collection(c: &Collection, linked_env_index: Option<usize>) -> Self {
Self {
name: c.name.clone(),
entries: if c.workspace_root.is_some() {
Vec::new()
} else {
c.entries.clone()
},
selected_entry: c.selected_entry,
path: c.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
git_origin: c.git_origin.clone(),
linked_env_index,
workspace_root: c
.workspace_root
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
workspace_filter_hurl_json: c
.workspace_root
.is_some()
.then_some(c.workspace_filter_hurl_json),
workspace_downloaded_from_git: c.workspace_downloaded_from_git,
workspace_git_origin: c.workspace_git_origin.clone(),
workspace_expanded_paths: c
.workspace_root
.as_ref()
.map(|root| {
let mut paths: Vec<String> = c
.workspace_expanded
.iter()
.filter_map(|abs| abs.strip_prefix(root).ok().map(rel_slashes))
.filter(|s| !s.is_empty())
.collect();
paths.sort(); paths
})
.unwrap_or_default(),
workspace_selected_path: match (&c.workspace_root, &c.workspace_selected) {
(Some(root), Some(sel)) => sel.strip_prefix(root).ok().map(rel_slashes),
_ => None,
},
}
}
pub fn into_collection(
self,
linked_env_id: Option<u64>,
) -> (Collection, Option<PendingWorkspaceReload>) {
let workspace_root = self
.workspace_root
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
let path = self
.path
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.map(crate::shared_utils::file_path);
let root_missing = workspace_root.as_ref().is_some_and(|r| !r.exists());
let pending_reload = if root_missing {
self.workspace_git_origin.clone().map(|origin| {
let relative_selected_path = match (&path, &workspace_root) {
(Some(p), Some(root)) => p
.strip_prefix(root)
.ok()
.map(|rel| rel.to_string_lossy().into_owned()),
_ => None,
};
PendingWorkspaceReload {
tab_name: self.name.clone(),
origin,
relative_selected_path,
}
})
} else {
None
};
let workspace_root = if root_missing { None } else { workspace_root };
let mut restored_entries = false;
let (entries, path) = if workspace_root.is_some() {
match path
.as_ref()
.filter(|p| p.exists())
.and_then(|p| std::fs::read_to_string(p).ok())
{
Some(content) => (crate::postman::parse_collection(&content), path),
None => (Vec::new(), None),
}
} else if root_missing {
(Vec::new(), None)
} else {
restored_entries = true;
(self.entries, path)
};
let baselines: Vec<Option<String>> = restored_entries
.then(|| entries.iter().map(|e| e.baseline.clone()).collect())
.unwrap_or_default();
let mut c = Collection::new(self.name, entries);
if restored_entries {
c.adopt_restored_entries(baselines);
}
c.selected_entry = self.selected_entry.min(c.entries.len().saturating_sub(1));
c.path = path;
if restored_entries {
c.repair_restored_baselines();
c.rebuild_restored_structure_baseline();
}
c.git_origin = self.git_origin;
c.linked_env_id = linked_env_id;
c.workspace_root = workspace_root;
c.workspace_filter_hurl_json = self.workspace_filter_hurl_json.unwrap_or(true);
c.workspace_downloaded_from_git = self.workspace_downloaded_from_git && !root_missing;
c.workspace_git_origin = if root_missing {
None
} else {
self.workspace_git_origin
};
c.sync_folder_to_selected();
c.workspace_expanded = if let Some(root) = &c.workspace_root {
self.workspace_expanded_paths
.iter()
.filter(|s| !s.is_empty())
.map(|rel| root.join(rel))
.collect()
} else {
std::collections::HashSet::new()
};
c.expand_ancestors_for_path();
c.rebuild_expanded_titles();
c.sync_ws_cursor();
c.workspace_selected = match (&c.workspace_root, &self.workspace_selected_path) {
(Some(root), Some(rel)) if !rel.is_empty() => {
Some(root.join(rel)).filter(|p| p.exists())
}
_ => None,
};
(c, pending_reload)
}
}
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct PersistedReport {
pub name: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub git_origin: Option<GitOrigin>,
#[serde(default)]
pub workspace_root: Option<String>,
#[serde(default = "default_true")]
pub embedded_active: bool,
}
fn default_true() -> bool {
true
}
#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)]
pub struct PersistedReportParams {
pub key: String,
pub values: Vec<(String, String)>,
}
impl PersistedReport {
pub fn from_report(r: &crate::report::Report) -> Self {
Self {
name: r.name.clone(),
text: r.text.clone(),
path: r.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
git_origin: r.git_origin.clone(),
workspace_root: None,
embedded_active: true,
}
}
pub fn into_report(self) -> crate::report::Report {
crate::report::Report {
id: crate::report::report::next_report_id(),
name: self.name,
text: self.text,
path: self.path.map(PathBuf::from),
git_origin: self.git_origin,
dirty: false,
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum GuiView {
#[default]
Requests,
Reports,
Report(usize),
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
pub struct GuiLayout {
#[serde(default)]
pub window: Option<(f32, f32)>,
#[serde(default)]
pub left_width: Option<f32>,
#[serde(default)]
pub env_height: Option<f32>,
#[serde(default)]
pub response_height: Option<f32>,
#[serde(default)]
pub report_diag_height: Option<f32>,
#[serde(default)]
pub report_palette_width: Option<f32>,
#[serde(default)]
pub report_summary_height: Option<f32>,
#[serde(default)]
pub report_detail_height: Option<f32>,
#[serde(default)]
pub view: GuiView,
#[serde(default)]
pub report_source_view: bool,
}
#[derive(Serialize, Deserialize)]
pub struct PersistedState {
#[serde(default, deserialize_with = "lenient")]
pub language: Language,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub tabs: Vec<PersistedTab>,
#[serde(default)]
pub reports: Vec<PersistedReport>,
#[serde(default)]
pub active_tab: usize,
#[serde(default)]
pub last_browse_dir: Option<String>,
#[serde(default)]
pub last_env_dir: Option<String>,
#[serde(default)]
pub last_import_dir: Option<String>,
#[serde(default = "yes")]
pub confirm_on_exit: bool,
#[serde(default = "yes")]
pub confirm_on_clear: bool,
#[serde(default = "yes")]
pub confirm_on_delete_env: bool,
#[serde(default = "yes")]
pub confirm_on_delete_request: bool,
#[serde(default)]
pub always_save_when_prompted: bool,
#[serde(default = "default_list_width")]
pub list_width: u16,
#[serde(default = "default_response_pct")]
pub response_pct: u16,
#[serde(default)]
pub recent_git_urls: Vec<String>,
#[serde(default)]
pub recent_key_refs: Vec<String>,
#[serde(default)]
pub report_params: Vec<PersistedReportParams>,
#[serde(default, deserialize_with = "lenient")]
pub default_request_view: RequestView,
#[serde(default)]
pub run_all_batch_mode: bool,
#[serde(default)]
pub discard_request_edits_on_esc: bool,
#[serde(default)]
pub custom_themes: Vec<crate::tui::theme::ThemeSpec>,
#[serde(default)]
pub active_theme: Option<String>,
#[serde(default)]
pub global_envs: Vec<PersistedEnv>,
#[serde(default)]
pub active_global_env: Option<usize>,
#[serde(default, deserialize_with = "lenient")]
pub env_source: EnvSource,
#[serde(default, deserialize_with = "lenient")]
pub gui: GuiLayout,
}
fn yes() -> bool {
true
}
fn default_list_width() -> u16 {
38
}
fn default_response_pct() -> u16 {
42
}
impl Default for PersistedState {
fn default() -> Self {
Self {
language: Language::default(),
base_url: String::new(),
tabs: Vec::new(),
reports: Vec::new(),
active_tab: 0,
last_browse_dir: None,
last_env_dir: None,
last_import_dir: None,
confirm_on_exit: true,
confirm_on_clear: true,
confirm_on_delete_env: true,
confirm_on_delete_request: true,
always_save_when_prompted: false,
list_width: default_list_width(),
response_pct: default_response_pct(),
recent_git_urls: Vec::new(),
recent_key_refs: Vec::new(),
report_params: Vec::new(),
default_request_view: RequestView::default(),
run_all_batch_mode: false,
discard_request_edits_on_esc: false,
custom_themes: Vec::new(),
active_theme: None,
global_envs: Vec::new(),
active_global_env: None,
env_source: EnvSource::Both,
gui: GuiLayout::default(),
}
}
}
fn state_path() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("PAPERBOY_STATE_DIR") {
return Some(PathBuf::from(dir).join("state.json"));
}
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.or_else(|| std::env::var_os("APPDATA").map(PathBuf::from))?;
Some(base.join("paperboy").join("state.json"))
}
pub(crate) fn lenient<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
pub fn load_state() -> Option<PersistedState> {
let path = state_path()?;
let text = fs::read_to_string(&path).ok()?;
match serde_json::from_str(&text) {
Ok(state) => Some(state),
Err(e) => {
let aside = path.with_extension("json.unreadable");
let _ = fs::rename(&path, &aside);
eprintln!(
"paperboy: could not read {} ({e}); it has been kept as {} and a fresh session started",
path.display(),
aside.display()
);
None
}
}
}
pub fn save_state(state: &PersistedState) {
if cfg!(test) {
return;
}
let Some(path) = state_path() else { return };
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(state) {
let _ = fs::write(path, json);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn persisted_report_round_trips_through_serde_and_back() {
let mut r = crate::report::Report::from_text(
"nightly",
"# name: Nightly\n# collection: c.hurl\nREQUEST Oauth\n",
);
r.path = Some(PathBuf::from("/tmp/nightly.trail"));
let persisted = PersistedReport::from_report(&r);
let json = serde_json::to_string(&persisted).unwrap();
let back: PersistedReport = serde_json::from_str(&json).unwrap();
let restored = back.into_report();
assert_eq!(restored.name, "Nightly");
assert_eq!(restored.text, r.text);
assert_eq!(restored.path, r.path);
assert!(!restored.dirty, "a restored report is not dirty");
}
fn saved_tab_for_a_three_request_file(tag: &str) -> (PathBuf, PersistedTab) {
let dir = std::env::temp_dir().join(format!("paperboy_restore_{tag}"));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("c.hurl");
let entries: Vec<HurlEntry> = ["one", "two", "three"]
.iter()
.map(|n| {
HurlEntry::from_fields(
n,
"GET",
&format!("https://example.com/{n}"),
Vec::new(),
"",
)
})
.collect();
let text: String = entries.iter().map(|e| e.to_hurl()).collect();
std::fs::write(&path, &text).unwrap();
let mut c = Collection::new("c".to_string(), entries);
c.path = Some(path.clone());
c.reset_structure_baseline();
(path, PersistedTab::from_collection(&c, None))
}
#[test]
fn a_deletion_left_unsaved_survives_a_restart() {
let (path, mut tab) = saved_tab_for_a_three_request_file("delete");
tab.entries.remove(1);
let (c, _) = tab.into_collection(None);
assert!(
c.has_unsaved_edits(),
"the file still holds the deleted request, so there is something to save"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_reorder_left_unsaved_survives_a_restart() {
let (path, mut tab) = saved_tab_for_a_three_request_file("reorder");
tab.entries.swap(0, 2);
let (c, _) = tab.into_collection(None);
assert!(
c.has_unsaved_edits(),
"the list is in a different order from the file"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn an_untouched_session_comes_back_clean() {
let (path, tab) = saved_tab_for_a_three_request_file("clean");
let (c, _) = tab.into_collection(None);
assert!(
!c.has_unsaved_edits(),
"nothing was changed, so there is nothing to save"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_workspaces_selected_node_round_trips_and_drops_when_it_vanishes() {
let root =
std::env::temp_dir().join(format!("paperboy_ws_selected_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("nightly")).unwrap();
let report = root.join("nightly/run.trail");
std::fs::write(&report, "REQUEST A\n").unwrap();
let mut c = Collection::new("ws".to_string(), Vec::new());
c.workspace_root = Some(root.clone());
c.workspace_selected = Some(report.clone());
let persisted = PersistedTab::from_collection(&c, None);
assert_eq!(
persisted.workspace_selected_path.as_deref(),
Some("nightly/run.trail"),
"stored relative to the root, with forward slashes"
);
let json = serde_json::to_string(&persisted).unwrap();
let back: PersistedTab = serde_json::from_str(&json).unwrap();
let (restored, _) = back.into_collection(None);
assert_eq!(restored.workspace_selected, Some(report.clone()));
std::fs::remove_file(&report).unwrap();
let json = serde_json::to_string(&PersistedTab::from_collection(&c, None)).unwrap();
let back: PersistedTab = serde_json::from_str(&json).unwrap();
let (restored, _) = back.into_collection(None);
assert_eq!(restored.workspace_selected, None);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_state_file_without_a_workspace_selection_still_loads() {
let tab: PersistedTab = serde_json::from_str(r#"{"name":"ws","entries":[]}"#).unwrap();
assert_eq!(tab.workspace_selected_path, None);
let (c, _) = tab.into_collection(None);
assert_eq!(c.workspace_selected, None);
}
#[test]
fn persisted_state_defaults_have_no_reports() {
let state = PersistedState::default();
assert!(state.reports.is_empty());
}
#[test]
fn an_unknown_setting_written_by_a_newer_build_costs_only_that_setting() {
let json = r#"{
"language": "English",
"base_url": "",
"tabs": [{"name": "Coll", "entries": [], "selected_entry": 0}],
"env_source": "OnlyRemote",
"default_request_view": "SomethingNewer"
}"#;
let state: PersistedState = serde_json::from_str(json).expect("the document still parses");
assert_eq!(state.tabs.len(), 1, "the open tab survives");
assert_eq!(state.tabs[0].name, "Coll");
assert_eq!(state.env_source, EnvSource::default());
assert_eq!(state.default_request_view, RequestView::default());
}
#[test]
fn a_form_field_kind_from_a_newer_build_costs_only_that_field() {
use crate::hurl::{CommentAnchor, EntryComment, FormField, FormFieldKind};
let mut entry =
crate::hurl::HurlEntry::from_fields("Upload", "POST", "https://h/x", vec![], "");
entry.form_fields = vec![
FormField {
key: "a".into(),
value: "1".into(),
kind: FormFieldKind::Text,
enabled: true,
..FormField::default()
},
FormField {
key: "b".into(),
value: "/tmp/x".into(),
kind: FormFieldKind::File,
enabled: true,
..FormField::default()
},
];
entry.comments = vec![EntryComment {
anchor: CommentAnchor::Body,
text: "# note".into(),
}];
let state = PersistedState {
tabs: vec![PersistedTab {
name: "Coll".into(),
entries: vec![entry],
..PersistedTab::default()
}],
..PersistedState::default()
};
let json = serde_json::to_string(&state)
.unwrap()
.replace("\"File\"", "\"Directory\"")
.replace("\"Body\"", "\"SomewhereNew\"");
let back: PersistedState = serde_json::from_str(&json).expect("the document still parses");
let entry = &back.tabs[0].entries[0];
assert_eq!(entry.title, "Upload", "the request survives");
assert_eq!(entry.form_fields.len(), 2, "both rows survive");
assert_eq!(
entry.form_fields[1].kind,
FormFieldKind::default(),
"only the unknown kind falls back"
);
assert_eq!(entry.comments.len(), 1, "and the comment is still there");
assert_eq!(entry.comments[0].anchor, CommentAnchor::default());
}
#[test]
fn confirm_on_delete_request_defaults_on_and_round_trips() {
let mut value = serde_json::to_value(PersistedState::default()).unwrap();
value
.as_object_mut()
.unwrap()
.remove("confirm_on_delete_request");
let older: PersistedState = serde_json::from_value(value).unwrap();
assert!(
older.confirm_on_delete_request,
"a document without the field defaults the guard on"
);
let mut session = crate::session::Session::default();
assert!(session.confirm_on_delete_request, "on by default");
session.confirm_on_delete_request = false;
let persisted = session.to_persisted();
assert!(!persisted.confirm_on_delete_request);
let mut restored = crate::session::Session::default();
restored.apply_persisted(persisted);
assert!(
!restored.confirm_on_delete_request,
"the choice is preserved across a save/load cycle"
);
}
}