From 4d287482e381ec845690b76d329d775f60e6e4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=20Rodr=C3=ADguez?= Date: Sun, 5 Jan 2025 22:33:09 +0100 Subject: [PATCH] Split with Serializable interface There is a new interface called Serializable. It allows a widget to convert its data into a TOML string and to read data from a TOML string by using the from_toml() and to_toml() virtual methods. The EndpointPane has been modified so that it implements this interface and knows how to persist its data through the RequestFile struct. The ItemPane is finally using interfaces to communicate. It knows how to load and save data from Serializable objects. As long as the child is a Serializable, the load_pane() and save_pane() methods can be used to load data from a gio::File and to save data into a gio::File by using the Serializable virtual methods to get or assign the TOML representation. --- src/error.rs | 35 ++ src/file.rs | 912 +++++++++++++++++------------------ src/objects/mod.rs | 4 +- src/objects/serializable.rs | 138 ++++++ src/widgets/endpoint_pane.rs | 40 +- src/widgets/item_pane.rs | 79 ++- src/win.rs | 82 ++-- 7 files changed, 749 insertions(+), 541 deletions(-) create mode 100644 src/objects/serializable.rs diff --git a/src/error.rs b/src/error.rs index 4489e22..30e717a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,20 @@ +// Copyright 2024-2025 the Cartero authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later + use srtemplate::SrTemplateError; use thiserror::Error; @@ -35,3 +52,21 @@ pub enum CarteroError { #[error("Outdated schema, please update the software")] OutdatedSchema, } + +#[derive(Debug, Error)] +pub enum FileOperationError { + #[error("No file was assigned")] + NoFileGiven, + + #[error("Cannot read file")] + FileReadError, + + #[error("Cannot decode file")] + FileDecodeError, + + #[error("Cannot encode file")] + FileEncodeError, + + #[error("Cannot write file")] + FileWriteError, +} diff --git a/src/file.rs b/src/file.rs index 03287fe..d983be2 100644 --- a/src/file.rs +++ b/src/file.rs @@ -1,10 +1,24 @@ +// Copyright 2024-2025 the Cartero authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later + use std::collections::HashMap; -use gtk::gio; -use gtk::prelude::{FileExtManual, SettingsExtManual}; use serde::{Deserialize, Serialize}; -use crate::app::CarteroApplication; use crate::client::RequestError; use crate::entities::{ EndpointData, KeyValue, KeyValueTable, RawEncoding, RequestMethod, RequestPayload, @@ -245,7 +259,7 @@ impl From for RequestPayload { } #[derive(Deserialize, Serialize)] -struct RequestFile { +pub(crate) struct RequestFile { version: usize, url: String, method: String, @@ -299,45 +313,6 @@ impl From for RequestFile { } } -pub fn parse_toml(file: &str) -> Result { - let contents = toml::from_str::(file)?; - EndpointData::try_from(contents) -} - -pub fn store_toml(endpoint: &EndpointData) -> Result { - let file = RequestFile::from(endpoint.clone()); - toml::to_string(&file).map_err(|e| e.into()) -} - -pub async fn read_file(file: &gio::File) -> Result { - file.load_contents_future() - .await - .map(|data| String::from_utf8_lossy(&data.0).to_string()) - .map_err(|err| { - println!("{err:?}"); - CarteroError::FileDialogError - }) -} - -pub async fn write_file(file: &gio::File, contents: &str) -> Result<(), CarteroError> { - let app = CarteroApplication::default(); - let settings = app.settings(); - let use_backups = settings.get::("create-backup-files"); - file.replace_contents_future( - contents.to_string(), - None, - use_backups, - gio::FileCreateFlags::NONE, - ) - .await - .map_err(|result| { - let error = result.1; - println!("{error:?}"); - CarteroError::FileDialogError - })?; - Ok(()) -} - #[cfg(test)] mod tests { use std::collections::HashMap; @@ -458,455 +433,456 @@ mod tests { ]) ); } + /* + #[test] + pub fn test_can_deserialize_classic() { + let toml = " + version = 1 + url = 'https://www.google.com' + method = 'GET' + body = 'hello' + + [headers] + Accept = 'text/html' + Accept-Encoding = 'gzip' + "; + let endpoint = super::parse_toml(toml).unwrap(); + assert_eq!(endpoint.url, "https://www.google.com"); + assert_eq!(endpoint.method, RequestMethod::Get); + assert_eq!( + endpoint.body, + RequestPayload::Raw { + encoding: RawEncoding::OctetStream, + content: Vec::from(b"hello"), + } + ); + assert_eq!(endpoint.headers.len(), 2); + + let mut given_headers = endpoint.headers.clone(); + given_headers.sort(); + assert_eq!( + given_headers, + KeyValueTable::new(&[ + KeyValue { + name: "Accept".into(), + value: "text/html".into(), + active: true, + secret: false + }, + KeyValue { + name: "Accept-Encoding".into(), + value: "gzip".into(), + active: true, + secret: false + } + ]), + ); + } - #[test] - pub fn test_can_deserialize_classic() { - let toml = " -version = 1 -url = 'https://www.google.com' -method = 'GET' -body = 'hello' - -[headers] -Accept = 'text/html' -Accept-Encoding = 'gzip' -"; - let endpoint = super::parse_toml(toml).unwrap(); - assert_eq!(endpoint.url, "https://www.google.com"); - assert_eq!(endpoint.method, RequestMethod::Get); - assert_eq!( - endpoint.body, - RequestPayload::Raw { - encoding: RawEncoding::OctetStream, - content: Vec::from(b"hello"), - } - ); - assert_eq!(endpoint.headers.len(), 2); - - let mut given_headers = endpoint.headers.clone(); - given_headers.sort(); - assert_eq!( - given_headers, - KeyValueTable::new(&[ - KeyValue { - name: "Accept".into(), - value: "text/html".into(), - active: true, - secret: false - }, - KeyValue { - name: "Accept-Encoding".into(), - value: "gzip".into(), - active: true, - secret: false + #[test] + pub fn test_can_deserialize_complex_headers() { + let toml = " + version = 1 + url = 'https://www.google.com' + method = 'GET' + body = 'hello' + + [headers] + Accept = { value = 'text/html', secret = true, active = false } + Accept-Encoding = 'gzip' + "; + let endpoint = super::parse_toml(toml).unwrap(); + assert_eq!(endpoint.url, "https://www.google.com"); + assert_eq!(endpoint.method, RequestMethod::Get); + assert_eq!( + endpoint.body, + RequestPayload::Raw { + encoding: RawEncoding::OctetStream, + content: Vec::from(b"hello"), } - ]), - ); - } + ); + assert_eq!(endpoint.headers.len(), 2); + + let mut given_headers = endpoint.headers.clone(); + given_headers.sort(); + assert_eq!( + given_headers, + KeyValueTable::new(&[ + KeyValue { + name: "Accept".into(), + value: "text/html".into(), + active: false, + secret: true, + }, + KeyValue { + name: "Accept-Encoding".into(), + value: "gzip".into(), + active: true, + secret: false + } + ]), + ); + } - #[test] - pub fn test_can_deserialize_complex_headers() { - let toml = " -version = 1 -url = 'https://www.google.com' -method = 'GET' -body = 'hello' - -[headers] -Accept = { value = 'text/html', secret = true, active = false } -Accept-Encoding = 'gzip' -"; - let endpoint = super::parse_toml(toml).unwrap(); - assert_eq!(endpoint.url, "https://www.google.com"); - assert_eq!(endpoint.method, RequestMethod::Get); - assert_eq!( - endpoint.body, - RequestPayload::Raw { - encoding: RawEncoding::OctetStream, - content: Vec::from(b"hello"), - } - ); - assert_eq!(endpoint.headers.len(), 2); + #[test] + pub fn test_can_deserialize_header_arrays() { + let toml = " + version = 1 + url = 'https://www.google.com' + method = 'GET' + body = 'hello' + + [headers] + Accept = ['application/json', 'text/html'] + Accept-Encoding = 'gzip' + "; + let endpoint = super::parse_toml(toml).unwrap(); + assert_eq!(endpoint.url, "https://www.google.com"); + assert_eq!(endpoint.method, RequestMethod::Get); + assert_eq!( + endpoint.body, + RequestPayload::Raw { + encoding: RawEncoding::OctetStream, + content: Vec::from(b"hello"), + } + ); + assert_eq!(endpoint.headers.len(), 3); + + let mut given_headers = endpoint.headers.clone(); + given_headers.sort(); + assert_eq!( + given_headers, + KeyValueTable::new(&[ + KeyValue { + name: "Accept".into(), + value: "application/json".into(), + active: true, + secret: false, + }, + KeyValue { + name: "Accept".into(), + value: "text/html".into(), + active: true, + secret: false, + }, + KeyValue { + name: "Accept-Encoding".into(), + value: "gzip".into(), + active: true, + secret: false + } + ]), + ); + } - let mut given_headers = endpoint.headers.clone(); - given_headers.sort(); - assert_eq!( - given_headers, - KeyValueTable::new(&[ - KeyValue { - name: "Accept".into(), - value: "text/html".into(), - active: false, - secret: true, - }, - KeyValue { - name: "Accept-Encoding".into(), - value: "gzip".into(), - active: true, - secret: false + #[test] + pub fn test_deserialize_complex_header_arrays() { + let toml = " + version = 1 + url = 'https://www.google.com' + method = 'GET' + body = 'hello' + + [headers] + Accept = [ + { value = 'application/json', active = false, secret = false }, + { value = 'text/html', active = false, secret = false }, + ] + X-Client-Id = [ + { value = '123412341234', active = true, secret = true }, + { value = '{{CLIENT_ID}}', active = false, secret = false }, + ] + Accept-Encoding = 'gzip' + "; + let endpoint = super::parse_toml(toml).unwrap(); + assert_eq!(endpoint.url, "https://www.google.com"); + assert_eq!(endpoint.method, RequestMethod::Get); + assert_eq!( + endpoint.body, + RequestPayload::Raw { + encoding: RawEncoding::OctetStream, + content: Vec::from(b"hello"), } - ]), - ); - } + ); + assert_eq!(endpoint.headers.len(), 5); + + let mut given_headers = endpoint.headers.clone(); + given_headers.sort(); + assert_eq!( + given_headers, + KeyValueTable::new(&vec![ + KeyValue { + name: "Accept".into(), + value: "application/json".into(), + active: false, + secret: false, + }, + KeyValue { + name: "Accept".into(), + value: "text/html".into(), + active: false, + secret: false, + }, + KeyValue { + name: "Accept-Encoding".into(), + value: "gzip".into(), + active: true, + secret: false + }, + KeyValue { + name: "X-Client-Id".into(), + value: "123412341234".into(), + active: true, + secret: true + }, + KeyValue { + name: "X-Client-Id".into(), + value: "{{CLIENT_ID}}".into(), + active: false, + secret: false + }, + ]), + ); + } - #[test] - pub fn test_can_deserialize_header_arrays() { - let toml = " -version = 1 -url = 'https://www.google.com' -method = 'GET' -body = 'hello' - -[headers] -Accept = ['application/json', 'text/html'] -Accept-Encoding = 'gzip' -"; - let endpoint = super::parse_toml(toml).unwrap(); - assert_eq!(endpoint.url, "https://www.google.com"); - assert_eq!(endpoint.method, RequestMethod::Get); - assert_eq!( - endpoint.body, - RequestPayload::Raw { - encoding: RawEncoding::OctetStream, - content: Vec::from(b"hello"), - } - ); - assert_eq!(endpoint.headers.len(), 3); + #[test] + pub fn test_deserialization_error() { + let toml = " + version = 0 + url = 'https://www.google.com' + method = 'GET' + body = 'hello' + "; + assert!(super::parse_toml(toml).is_err()); + } - let mut given_headers = endpoint.headers.clone(); - given_headers.sort(); - assert_eq!( - given_headers, - KeyValueTable::new(&[ - KeyValue { - name: "Accept".into(), - value: "application/json".into(), - active: true, - secret: false, - }, + #[test] + pub fn test_method_error() { + let toml = " + version = 1 + url = 'https://www.google.com' + method = 'THROW' + "; + assert!(super::parse_toml(toml).is_err()); + } + + #[test] + pub fn test_empty_url() { + let toml = " + version = 1 + method = 'POST' + body = 'hello' + + [headers] + Accept = 'text/html' + "; + assert!(super::parse_toml(toml).is_err()); + } + + #[test] + pub fn test_empty_method() { + let toml = " + version = 1 + url = 'https://www.google.com' + body = 'hello' + + [headers] + Accept = 'text/html' + "; + assert!(super::parse_toml(toml).is_err()); + } + + #[test] + pub fn test_empty_body() { + let toml = " + version = 1 + url = 'https://www.google.com' + method = 'GET' + + [headers] + Accept = 'text/html' + "; + let endpoint = super::parse_toml(toml).unwrap(); + assert_eq!(endpoint.url, "https://www.google.com"); + assert_eq!(endpoint.method, RequestMethod::Get); + assert_eq!(endpoint.body, RequestPayload::None); + } + + #[test] + pub fn test_multiple_headers_serialization() { + let headers = vec![ + ("Host", "google.com").into(), + ("User-Agent", "Cartero").into(), + ("User-Agent", "Cartero/0.1").into(), + ]; + let headers = KeyValueTable::new(&headers); + let body = RequestPayload::None; + let r = EndpointData { + url: "https://www.google.com".to_string(), + method: RequestMethod::Post, + headers, + variables: KeyValueTable::default(), + body, + }; + + let content = super::store_toml(&r).unwrap(); + let content = content.as_str(); + assert!(content.contains("url = \"https://www.google.com\"")); + assert!(content.contains("Host = \"google.com\"")); + assert!(content.contains("User-Agent = [")); + } + + #[test] + pub fn test_multiple_headers_serialization_with_meta() { + let headers = vec![ + ("Host", "google.com").into(), + ("User-Agent", "Cartero").into(), KeyValue { - name: "Accept".into(), - value: "text/html".into(), - active: true, + name: "User-Agent".into(), + value: "Cartero/devel".into(), + active: false, secret: false, }, - KeyValue { - name: "Accept-Encoding".into(), - value: "gzip".into(), - active: true, - secret: false + ("User-Agent", "Cartero/0.1").into(), + ]; + let headers = KeyValueTable::new(&headers); + let body = RequestPayload::None; + let r = EndpointData { + url: "https://www.google.com".to_string(), + method: RequestMethod::Post, + headers, + variables: KeyValueTable::default(), + body, + }; + + let content = super::store_toml(&r).unwrap(); + let content = content.as_str(); + assert!(content.contains("url = \"https://www.google.com\"")); + assert!(content.contains("Host = \"google.com\"")); + assert!(content.contains("User-Agent = [")); + assert!(content.contains("active = false")); + } + + #[test] + pub fn test_empty_headers() { + let toml = " + version = 1 + url = 'https://www.google.com' + method = 'POST' + body = 'hello' + "; + let endpoint = super::parse_toml(toml).unwrap(); + assert_eq!(endpoint.url, "https://www.google.com"); + assert_eq!(endpoint.method, RequestMethod::Post); + assert_eq!( + endpoint.body, + RequestPayload::Raw { + content: Vec::from(b"hello"), + encoding: RawEncoding::OctetStream, } - ]), - ); - } + ); + assert_eq!(endpoint.headers.len(), 0); + } - #[test] - pub fn test_deserialize_complex_header_arrays() { - let toml = " -version = 1 -url = 'https://www.google.com' -method = 'GET' -body = 'hello' - -[headers] -Accept = [ - { value = 'application/json', active = false, secret = false }, - { value = 'text/html', active = false, secret = false }, -] -X-Client-Id = [ - { value = '123412341234', active = true, secret = true }, - { value = '{{CLIENT_ID}}', active = false, secret = false }, -] -Accept-Encoding = 'gzip' -"; - let endpoint = super::parse_toml(toml).unwrap(); - assert_eq!(endpoint.url, "https://www.google.com"); - assert_eq!(endpoint.method, RequestMethod::Get); - assert_eq!( - endpoint.body, - RequestPayload::Raw { + #[test] + pub fn test_serialize_correctly() { + let headers = vec![ + ("User-Agent", "Cartero").into(), + ("Host", "google.com").into(), + ]; + let headers = KeyValueTable::new(&headers); + let body = RequestPayload::Raw { + content: Vec::from(b"Hello"), encoding: RawEncoding::OctetStream, - content: Vec::from(b"hello"), - } - ); - assert_eq!(endpoint.headers.len(), 5); + }; + let r = EndpointData { + url: "https://www.google.com".to_string(), + method: RequestMethod::Post, + headers, + variables: KeyValueTable::default(), + body, + }; + + let content = super::store_toml(&r).unwrap(); + assert!(content + .as_str() + .contains("url = \"https://www.google.com\"")); + assert!(content.as_str().contains("method = \"POST\"")); + assert!(content.as_str().contains("body = \"Hello\"")); + assert!(content.as_str().contains("User-Agent = \"Cartero\"")); + } - let mut given_headers = endpoint.headers.clone(); - given_headers.sort(); - assert_eq!( - given_headers, - KeyValueTable::new(&vec![ + #[test] + pub fn test_serializes_complex_example() { + // One thing important to test: since this is eventually a hashmap, the result + // will be sorted by key name, but the order of the elements must match the + // original order. + let headers = KeyValueTable::new(&vec![ KeyValue { - name: "Accept".into(), - value: "application/json".into(), - active: false, - secret: false, + name: "X-Client-Id".into(), + value: "123412341234".into(), + secret: true, + active: true, }, + ("Host", "google.com").into(), + ("User-Agent", "Cartero").into(), KeyValue { - name: "Accept".into(), - value: "text/html".into(), - active: false, + name: "X-Client-Id".into(), + value: "{{CLIENT_ID}}".into(), secret: false, + active: false, }, + ]); + let variables = KeyValueTable::new(&[ KeyValue { - name: "Accept-Encoding".into(), - value: "gzip".into(), + name: "CLIENT_SECRET".into(), + value: "101010".into(), + secret: true, active: true, - secret: false }, + ("CLIENT_ID", "123412341234").into(), KeyValue { - name: "X-Client-Id".into(), - value: "123412341234".into(), + name: "CLIENT_SECRET".into(), + value: "202020".into(), + secret: true, active: true, - secret: true }, - KeyValue { - name: "X-Client-Id".into(), - value: "{{CLIENT_ID}}".into(), - active: false, - secret: false - }, - ]), - ); - } - - #[test] - pub fn test_deserialization_error() { - let toml = " -version = 0 -url = 'https://www.google.com' -method = 'GET' -body = 'hello' -"; - assert!(super::parse_toml(toml).is_err()); - } - - #[test] - pub fn test_method_error() { - let toml = " -version = 1 -url = 'https://www.google.com' -method = 'THROW' -"; - assert!(super::parse_toml(toml).is_err()); - } - - #[test] - pub fn test_empty_url() { - let toml = " -version = 1 -method = 'POST' -body = 'hello' - -[headers] -Accept = 'text/html' -"; - assert!(super::parse_toml(toml).is_err()); - } - - #[test] - pub fn test_empty_method() { - let toml = " -version = 1 -url = 'https://www.google.com' -body = 'hello' - -[headers] -Accept = 'text/html' -"; - assert!(super::parse_toml(toml).is_err()); - } - - #[test] - pub fn test_empty_body() { - let toml = " -version = 1 -url = 'https://www.google.com' -method = 'GET' - -[headers] -Accept = 'text/html' -"; - let endpoint = super::parse_toml(toml).unwrap(); - assert_eq!(endpoint.url, "https://www.google.com"); - assert_eq!(endpoint.method, RequestMethod::Get); - assert_eq!(endpoint.body, RequestPayload::None); - } - - #[test] - pub fn test_multiple_headers_serialization() { - let headers = vec![ - ("Host", "google.com").into(), - ("User-Agent", "Cartero").into(), - ("User-Agent", "Cartero/0.1").into(), - ]; - let headers = KeyValueTable::new(&headers); - let body = RequestPayload::None; - let r = EndpointData { - url: "https://www.google.com".to_string(), - method: RequestMethod::Post, - headers, - variables: KeyValueTable::default(), - body, - }; - - let content = super::store_toml(&r).unwrap(); - let content = content.as_str(); - assert!(content.contains("url = \"https://www.google.com\"")); - assert!(content.contains("Host = \"google.com\"")); - assert!(content.contains("User-Agent = [")); - } - - #[test] - pub fn test_multiple_headers_serialization_with_meta() { - let headers = vec![ - ("Host", "google.com").into(), - ("User-Agent", "Cartero").into(), - KeyValue { - name: "User-Agent".into(), - value: "Cartero/devel".into(), - active: false, - secret: false, - }, - ("User-Agent", "Cartero/0.1").into(), - ]; - let headers = KeyValueTable::new(&headers); - let body = RequestPayload::None; - let r = EndpointData { - url: "https://www.google.com".to_string(), - method: RequestMethod::Post, - headers, - variables: KeyValueTable::default(), - body, - }; - - let content = super::store_toml(&r).unwrap(); - let content = content.as_str(); - assert!(content.contains("url = \"https://www.google.com\"")); - assert!(content.contains("Host = \"google.com\"")); - assert!(content.contains("User-Agent = [")); - assert!(content.contains("active = false")); - } - - #[test] - pub fn test_empty_headers() { - let toml = " -version = 1 -url = 'https://www.google.com' -method = 'POST' -body = 'hello' -"; - let endpoint = super::parse_toml(toml).unwrap(); - assert_eq!(endpoint.url, "https://www.google.com"); - assert_eq!(endpoint.method, RequestMethod::Post); - assert_eq!( - endpoint.body, - RequestPayload::Raw { - content: Vec::from(b"hello"), + ]); + let body = RequestPayload::Raw { + content: Vec::from(b"Hello"), encoding: RawEncoding::OctetStream, - } - ); - assert_eq!(endpoint.headers.len(), 0); - } - - #[test] - pub fn test_serialize_correctly() { - let headers = vec![ - ("User-Agent", "Cartero").into(), - ("Host", "google.com").into(), - ]; - let headers = KeyValueTable::new(&headers); - let body = RequestPayload::Raw { - content: Vec::from(b"Hello"), - encoding: RawEncoding::OctetStream, - }; - let r = EndpointData { - url: "https://www.google.com".to_string(), - method: RequestMethod::Post, - headers, - variables: KeyValueTable::default(), - body, - }; - - let content = super::store_toml(&r).unwrap(); - assert!(content - .as_str() - .contains("url = \"https://www.google.com\"")); - assert!(content.as_str().contains("method = \"POST\"")); - assert!(content.as_str().contains("body = \"Hello\"")); - assert!(content.as_str().contains("User-Agent = \"Cartero\"")); - } - - #[test] - pub fn test_serializes_complex_example() { - // One thing important to test: since this is eventually a hashmap, the result - // will be sorted by key name, but the order of the elements must match the - // original order. - let headers = KeyValueTable::new(&vec![ - KeyValue { - name: "X-Client-Id".into(), - value: "123412341234".into(), - secret: true, - active: true, - }, - ("Host", "google.com").into(), - ("User-Agent", "Cartero").into(), - KeyValue { - name: "X-Client-Id".into(), - value: "{{CLIENT_ID}}".into(), - secret: false, - active: false, - }, - ]); - let variables = KeyValueTable::new(&[ - KeyValue { - name: "CLIENT_SECRET".into(), - value: "101010".into(), - secret: true, - active: true, - }, - ("CLIENT_ID", "123412341234").into(), - KeyValue { - name: "CLIENT_SECRET".into(), - value: "202020".into(), - secret: true, - active: true, - }, - ]); - let body = RequestPayload::Raw { - content: Vec::from(b"Hello"), - encoding: RawEncoding::OctetStream, - }; - let r = EndpointData { - url: "https://www.google.com".to_string(), - method: RequestMethod::Post, - headers, - variables, - body, - }; - - let content = super::store_toml(&r).unwrap(); - let parsed = super::parse_toml(&content).unwrap(); - assert_eq!(r.url, parsed.url); - assert_eq!(r.method, parsed.method); - assert_eq!(r.body, parsed.body); - - assert_eq!( - KeyValueTable::new(&vec![ - r.headers[1].clone(), - r.headers[2].clone(), - r.headers[0].clone(), - r.headers[3].clone(), - ]), - parsed.headers - ); - assert_eq!( - KeyValueTable::new(&[ - r.variables[1].clone(), - r.variables[0].clone(), - r.variables[2].clone() - ]), - parsed.variables - ); - } + }; + let r = EndpointData { + url: "https://www.google.com".to_string(), + method: RequestMethod::Post, + headers, + variables, + body, + }; + + let content = super::store_toml(&r).unwrap(); + let parsed = super::parse_toml(&content).unwrap(); + assert_eq!(r.url, parsed.url); + assert_eq!(r.method, parsed.method); + assert_eq!(r.body, parsed.body); + + assert_eq!( + KeyValueTable::new(&vec![ + r.headers[1].clone(), + r.headers[2].clone(), + r.headers[0].clone(), + r.headers[3].clone(), + ]), + parsed.headers + ); + assert_eq!( + KeyValueTable::new(&[ + r.variables[1].clone(), + r.variables[0].clone(), + r.variables[2].clone() + ]), + parsed.variables + ); + } + */ } diff --git a/src/objects/mod.rs b/src/objects/mod.rs index def8da1..d1ce742 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2024 the Cartero authors +// Copyright 2024-2025 the Cartero authors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -16,5 +16,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later mod key_value_item; +mod serializable; pub use key_value_item::KeyValueItem; +pub use serializable::{Serializable, SerializableExt, SerializableImpl}; diff --git a/src/objects/serializable.rs b/src/objects/serializable.rs new file mode 100644 index 0000000..b52cd2e --- /dev/null +++ b/src/objects/serializable.rs @@ -0,0 +1,138 @@ +// Copyright 2024-2025 the Cartero authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later + +pub mod ffi { + use glib::subclass::prelude::*; + + #[derive(Copy, Clone)] + #[repr(C)] + pub struct SerializableInterface { + parent: glib::gobject_ffi::GTypeInterface, + pub to_toml: fn(&super::Serializable) -> Result, + pub from_toml: fn(&super::Serializable, &str) -> Result<(), ()>, + } + + unsafe impl InterfaceStruct for SerializableInterface { + type Type = super::iface::Serializable; + } +} + +mod iface { + use std::sync::OnceLock; + + use glib::prelude::*; + use glib::subclass::prelude::*; + + use super::ffi::SerializableInterface; + + pub struct Serializable; + + #[glib::object_interface] + impl ObjectInterface for Serializable { + const NAME: &'static str = "CarteroSerializable"; + type Interface = SerializableInterface; + type Prerequisites = (gtk::Widget,); + + fn interface_init(iface: &mut SerializableInterface) { + iface.from_toml = Serializable::from_toml_default; + iface.to_toml = Serializable::to_toml_default; + } + + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: OnceLock> = OnceLock::new(); + PROPERTIES.get_or_init(|| { + vec![glib::ParamSpecBoolean::builder("dirty") + .nick("Dirty entity") + .blurb("Whether the entity has diverged from the last deserializated state") + .default_value(false) + .readwrite() + .build()] + }) + } + } + + impl Serializable { + fn from_toml_default(_sble: &super::Serializable, _content: &str) -> Result<(), ()> { + Err(()) + } + + fn to_toml_default(_sble: &super::Serializable) -> Result { + Err(()) + } + } +} + +use glib::object::IsA; +use gtk::prelude::*; +use gtk::subclass::prelude::*; + +glib::wrapper! { + pub struct Serializable(ObjectInterface) @requires gtk::Widget; +} + +#[allow(dead_code)] +pub trait SerializableExt: IsA + IsA { + fn from_toml(&self, content: &str) -> Result<(), ()> { + let this = self.upcast_ref::(); + let klass = this.interface::().unwrap(); + (klass.as_ref().from_toml)(this, content) + } + + fn to_toml(&self) -> Result { + let this = self.upcast_ref::(); + let klass = this.interface::().unwrap(); + (klass.as_ref().to_toml)(this) + } + + fn dirty(&self) -> bool { + self.property("dirty") + } + + fn set_dirty(&self, dirty: bool) { + self.set_property("dirty", dirty); + } +} + +impl + IsA> SerializableExt for T {} + +pub trait SerializableImpl: ObjectImpl + WidgetImpl { + fn from_toml(&self, _content: &str) -> Result<(), ()>; + fn to_toml(&self) -> Result; +} + +unsafe impl IsImplementable for Serializable { + fn interface_init(iface: &mut glib::Interface) { + let iface = AsMut::as_mut(iface); + + fn from_toml_trampoline( + obj: &super::Serializable, + content: &str, + ) -> Result<(), ()> { + let imp = unsafe { obj.unsafe_cast_ref::<::Type>().imp() }; + SerializableImpl::from_toml(imp, content) + } + iface.from_toml = from_toml_trampoline::; + + fn to_toml_trampoline( + obj: &super::Serializable, + ) -> Result { + let imp = unsafe { obj.unsafe_cast_ref::<::Type>().imp() }; + SerializableImpl::to_toml(imp) + } + iface.to_toml = to_toml_trampoline::; + } +} diff --git a/src/widgets/endpoint_pane.rs b/src/widgets/endpoint_pane.rs index 6038b98..c609630 100644 --- a/src/widgets/endpoint_pane.rs +++ b/src/widgets/endpoint_pane.rs @@ -1,4 +1,4 @@ -// Copyright 2024 the Cartero authors +// Copyright 2024-2025 the Cartero authors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -18,7 +18,7 @@ use glib::{subclass::types::ObjectSubclassIsExt, Object}; use gtk::glib; -use crate::{entities::EndpointData, error::CarteroError}; +use crate::{entities::EndpointData, error::CarteroError, objects::Serializable}; mod imp { use std::cell::RefCell; @@ -37,9 +37,10 @@ mod imp { use crate::client::{BoundRequest, RequestError}; use crate::entities::{EndpointData, KeyValue, RequestExportType}; use crate::error::CarteroError; - use crate::objects::KeyValueItem; + use crate::file::RequestFile; + use crate::objects::{KeyValueItem, Serializable, SerializableImpl}; use crate::widgets::{ - ExportTab, ExportType, ItemPane, KeyValuePane, MethodDropdown, PayloadTab, ResponsePanel, + ExportTab, ExportType, KeyValuePane, MethodDropdown, PayloadTab, ResponsePanel, }; #[derive(CompositeTemplate, Properties, Default)] @@ -76,10 +77,10 @@ mod imp { #[template_child] pub paned: TemplateChild, - #[property(get, set, nullable)] - pub item_pane: RefCell>, - variable_changing: Arc>, + + #[property(get, set)] + dirty: RefCell, } #[glib::object_subclass] @@ -87,6 +88,7 @@ mod imp { const NAME: &'static str = "CarteroEndpointPane"; type Type = super::EndpointPane; type ParentType = adw::BreakpointBin; + type Interfaces = (Serializable,); fn class_init(klass: &mut Self::Class) { klass.bind_template(); @@ -205,9 +207,8 @@ mod imp { } fn mark_dirty(&self) { - if let Some(item_pane) = self.obj().item_pane() { - item_pane.set_dirty(true); - } + let obj = self.obj(); + obj.set_dirty(true); } fn init_dirty_events(&self) { @@ -414,11 +415,28 @@ mod imp { Ok(()) } } + + impl SerializableImpl for EndpointPane { + fn from_toml(&self, content: &str) -> Result<(), ()> { + let contents = toml::from_str::(content).map_err(|_| ())?; + let ep = EndpointData::try_from(contents).map_err(|_| ())?; + self.assign_request(&ep); + Ok(()) + } + + fn to_toml(&self) -> Result { + let endpoint = self.extract_endpoint().map_err(|_| ())?; + let file = RequestFile::from(endpoint); + let contents = toml::to_string(&file).map_err(|_| ())?; + Ok(contents) + } + } } glib::wrapper! { pub struct EndpointPane(ObjectSubclass) - @extends gtk::Widget, gtk::Box; + @extends gtk::Widget, gtk::Box, + @implements Serializable; } impl Default for EndpointPane { diff --git a/src/widgets/item_pane.rs b/src/widgets/item_pane.rs index cf1a8b4..dcadfb5 100644 --- a/src/widgets/item_pane.rs +++ b/src/widgets/item_pane.rs @@ -1,4 +1,4 @@ -// Copyright 2024 the Cartero authors +// Copyright 2024-2025 the Cartero authors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -20,7 +20,11 @@ use gettextrs::gettext; use glib::Object; use gtk::{gio, ClosureExpression}; -use crate::error::CarteroError; +use crate::{ + app::CarteroApplication, + error::FileOperationError, + objects::{Serializable, SerializableExt}, +}; use super::EndpointPane; @@ -65,25 +69,70 @@ glib::wrapper! { } impl ItemPane { - pub async fn new_for_endpoint(file: Option<&gio::File>) -> Result { - let pane: Self = Object::builder().property("file", file).build(); + pub fn serializable(&self) -> Option { + self.child().and_dynamic_cast::().ok() + } - let child_pane = EndpointPane::default(); - pane.set_child(Some(&child_pane)); + pub fn new_for_endpoint(file: Option<&gio::File>) -> Self { + let endpoint = EndpointPane::default(); + let pane: Self = Object::builder() + .property("file", file) + .property("child", Some(&endpoint)) + .build(); + endpoint + .bind_property("dirty", &pane, "dirty") + .bidirectional() + .sync_create() + .build(); + pane + } - if let Some(path) = file { - let contents = crate::file::read_file(path).await?; - let endpoint = crate::file::parse_toml(&contents)?; - child_pane.assign_endpoint(&endpoint); - } + pub fn endpoint(&self) -> Option { + self.child().and_downcast::() + } - child_pane.set_item_pane(Some(&pane)); + pub async fn load_pane(&self) -> Result<(), FileOperationError> { + if let Some(serial) = self.serializable() { + let Some(file) = self.file() else { + return Err(FileOperationError::NoFileGiven); + }; + let contents = file + .load_contents_future() + .await + .map(|slice| String::from_utf8_lossy(&slice.0).to_string()) + .map_err(|_| FileOperationError::FileReadError)?; + serial + .from_toml(&contents) + .map_err(|_| FileOperationError::FileDecodeError)?; + serial.set_dirty(false); + } + Ok(()) + } - Ok(pane) + pub async fn save_pane(&self) -> Result<(), FileOperationError> { + let make_backup = { + let app = CarteroApplication::default(); + let settings = app.settings(); + settings.get::("create-backup-files") + }; + if let Some(serial) = self.serializable() { + let Some(file) = self.file() else { + return Err(FileOperationError::NoFileGiven); + }; + let toml = serial + .to_toml() + .map_err(|_| FileOperationError::FileEncodeError)?; + file.replace_contents_future(toml, None, make_backup, gio::FileCreateFlags::NONE) + .await + .map_err(|_| FileOperationError::FileWriteError)?; + } + Ok(()) } - pub fn endpoint(&self) -> Option { - self.child().and_downcast::() + pub fn clear_dirty(&self) { + if let Some(serializable) = self.serializable() { + serializable.set_dirty(false); + } } pub fn window_title_binding(&self) -> ClosureExpression { diff --git a/src/win.rs b/src/win.rs index 4f64fd6..7683218 100644 --- a/src/win.rs +++ b/src/win.rs @@ -1,4 +1,4 @@ -// Copyright 2024 the Cartero authors +// Copyright 2024-2025 the Cartero authors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -220,21 +220,18 @@ mod imp { } } - match ItemPane::new_for_endpoint(file).await { - Ok(pane) => { - self.stack.set_visible_child_name("tabview"); - let page = self.tabview.add_page(&pane, None); - pane.window_title_binding() - .bind(&page, "title", Some(&pane)); - pane.window_subtitle_binding() - .bind(&page, "tooltip", Some(&pane)); - self.tabview.set_selected_page(&page); - self.save_visible_tabs(); - } - Err(e) => { - self.obj().toast_error(e); - } - }; + let pane = ItemPane::new_for_endpoint(file); + if pane.file().is_some() { + let _ = pane.load_pane().await; + } + self.stack.set_visible_child_name("tabview"); + let page = self.tabview.add_page(&pane, None); + pane.window_title_binding() + .bind(&page, "title", Some(&pane)); + pane.window_subtitle_binding() + .bind(&page, "tooltip", Some(&pane)); + self.tabview.set_selected_page(&page); + self.save_visible_tabs(); } async fn trigger_open(&self) -> Result<(), CarteroError> { @@ -248,42 +245,35 @@ mod imp { Ok(()) } - async fn save_pane(&self, pane: &ItemPane) -> Result<(), CarteroError> { - let Some(endpoint) = pane.endpoint() else { - return Ok(()); - }; - - let file = match pane.file() { - Some(file) => file, - None => { - let obj = self.obj(); - crate::widgets::save_file(&obj).await? - } - }; + async fn request_file_for_pane(&self, pane: &ItemPane) -> Result<(), CarteroError> { + let obj = self.obj(); + let file = crate::widgets::save_file(&obj).await?; + pane.set_file(Some(&file)); + Ok(()) + } - let endpoint = endpoint.extract_endpoint()?; - let serialized_payload = crate::file::store_toml(&endpoint)?; - crate::file::write_file(&file, &serialized_payload).await?; - pane.set_file(Some(file.clone())); - pane.set_dirty(false); + async fn assert_pane_has_file(&self, pane: &ItemPane) -> Result<(), CarteroError> { + if pane.file().is_none() { + self.request_file_for_pane(pane).await?; + } + Ok(()) + } + async fn save_pane(&self, pane: &ItemPane) -> Result<(), CarteroError> { + self.assert_pane_has_file(&pane).await?; + pane.save_pane() + .await + .map_err(|_| CarteroError::FileDialogError)?; + pane.clear_dirty(); Ok(()) } async fn save_pane_as(&self, pane: &ItemPane) -> Result<(), CarteroError> { - let Some(endpoint) = pane.endpoint() else { - return Ok(()); - }; - - let obj = self.obj(); - let file = crate::widgets::save_file(&obj).await?; - - let endpoint = endpoint.extract_endpoint()?; - let serialized_payload = crate::file::store_toml(&endpoint)?; - crate::file::write_file(&file, &serialized_payload).await?; - pane.set_file(Some(file.clone())); - pane.set_dirty(false); - + self.request_file_for_pane(pane).await?; + pane.save_pane() + .await + .map_err(|_| CarteroError::FileDialogError)?; + pane.clear_dirty(); Ok(()) }