v0.1.4-pre.006

This commit is contained in:
2026-08-16 11:38:07 +02:00
parent abe3f8daa6
commit 34f6f448fb
18 changed files with 669 additions and 50 deletions

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-app-config-desk/Cargo.toml
# version: 2
# version: 3
[package]
name = "ksp-app-config-desk"
@@ -23,9 +23,13 @@ tauri-build.workspace = true
[dependencies]
fs2.workspace = true
ksp-config-lib = { path = "../ksp-config-lib" }
ksp-core-lib = { path = "../ksp-core-lib" }
ksp-logging-lib = { path = "../ksp-logging-lib" }
serde.workspace = true
tauri.workspace = true
tauri-plugin-tracing.workspace = true
ts-rs.workspace = true
[lints]
workspace = true

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# `ksp-app-config-desk`
@@ -27,7 +27,8 @@ Le gabarit desktop actif fournit maintenant :
- SimpleBar + `resize-observer-polyfill` pour le scrolling du shell ;
- `tauri-plugin-tracing` côté Rust et `@fltsci/tauri-plugin-tracing` côté frontend ;
- les ports dédiés `1430` pour Vite HTTP et `1431` pour le WebSocket de développement ;
- la destination frontend externe commune à Tauri et Vite.
- la destination frontend externe commune à Tauri et Vite;
- le bootstrap Config/Logging réel, un `AppState` durable et le premier contrat Tauri/TS-RS `get_app_snapshot`.
Le bridge de logging frontend KSP à target explicite n'est pas encore introduit : il appartient à une tranche dédiée afin de passer par `ksp-logging-lib` et de ne pas figer le target vide du bridge JS générique comme contrat KSP.
@@ -51,13 +52,13 @@ frontend/
└── splash.html
```
Les futurs bindings TS-RS seront générés sous :
Les premiers bindings TS-RS sont générés par les tests `ts-rs` sous :
```text
frontend/ts/bindings/ksp_app_config_desk/...
```
Le répertoire de bindings n'est pas créé artificiellement avant le premier DTO réel.
Les DTO Rust applicatifs restent la source de vérité et les bindings générés ne sont pas versionnés.
## Artefacts frontend
@@ -85,3 +86,16 @@ Le port HTTP est strict. Les futures applications desk utilisent les couples sui
Les bibliothèques consommées par le bundle applicatif sont déclarées sous `dependencies` : `@fltsci/tauri-plugin-tracing`, `@fortawesome/fontawesome-free`, `@tauri-apps/api`, `bootstrap`, `simplebar` et `resize-observer-polyfill`.
Les outils de compilation/développement et déclarations de types appartiennent à `devDependencies`, notamment `@tauri-apps/cli`, `@types/bootstrap`, `@types/node`, `sass-embedded`, `typescript` et `vite`. Les commandes npm directes sont réservées à l'installation ou à la mise à jour de ces dépendances ; le cycle normal de développement et de build est lancé depuis la racine du workspace par Tauri avec la configuration explicite de l'application.
## Bootstrap Config et Logging
Au démarrage, l'application construit `ConfigBootstrapOptions`, `ConfigFileRegistry`, `ConfigDocumentEngine` et `ConfigManagement` à partir des arguments complets du processus. Elle tente ensuite de résoudre le profil Logging par défaut avec un snapshot `ConfigEnvironment` frais.
Si le document Logging, sa résolution environnementale ou l'initialisation du runtime configuré échoue avant installation du subscriber, Config Desk installe un fallback **transitoire** en mémoire : console stderr, niveau `Info`, aucun fichier, aucun span lifecycle. Ce fallback n'est jamais persisté et son diagnostic frontend est limité à `domain`, `code` et `message`.
Le `LoggingGuard` est conservé dans `AppState` pour préparer le hot reload ultérieur.
## Police du splash
Le splash bot3 de référence utilise `DOS_Amazigh.ttf` (`font-family: Dos Amazigh`). Le répertoire `frontend/fonts/` documente la provenance, la destination attendue et l'empreinte de l'asset de référence. Le style/lifecycle final du splash est traité dans la tranche dédiée `pre.008`.

View File

@@ -1,16 +1,15 @@
<!-- file: crates/ksp-app-config-desk/TODO.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# TODO `ksp-app-config-desk`
Le gabarit frontend de base est maintenant en place : Vite/TypeScript/SCSS, Bootstrap, Font Awesome, SimpleBar, resize observer, plugin tracing et ports dédiés.
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable et les premiers DTO TS-RS sont maintenant présents.
Les prochaines tranches doivent encore :
- introduire les DTO TS-RS au premier contrat Tauri réel, avec bindings générés sous `frontend/ts/bindings/` ;
- introduire `AppState`, `ksp-config-lib`, `ksp-logging-lib` et l'ownership durable de `LoggingGuard` ;
- reprendre/refondre `frontend_log.ts` depuis bot3 avec targets KSP whitelistés et émission via `ksp-logging-lib` ;
- finaliser le lifecycle splash configurable sans temporisation environnementale codée en dur ;
- appliquer la police `Dos Amazigh` du gabarit bot3 lorsque l'asset local `DOS_Amazigh.ttf` est présent sous `frontend/fonts/` ;
- développer les panneaux Documents, Profils, Environnement/`.env`, Secrets et Logging ;
- introduire DataTables au premier tableau qui exige tri, filtrage, sélection ou checkbox ;
- démontrer le hot reload Logging et le panneau de test de routage.

View File

@@ -1,11 +1,11 @@
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Utilisation de `ksp-app-config-desk`
## État actuel
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont maintenant présents. Les panneaux métier Config restent volontairement absents à ce stade.
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise désormais Config, le runtime Logging et `AppState`; les panneaux métier Config restent volontairement absents à ce stade.
La fenêtre `splash` est la fenêtre visible au démarrage. La fenêtre `main` est déjà construite et bundlée, mais son lifecycle d'affichage est finalisé dans la tranche splash/shell dédiée afin de ne pas introduire une temporisation codée en dur.
@@ -86,3 +86,16 @@ frontend/ts/bindings/ksp_app_config_desk/...
```
Les bindings sont générés au premier DTO Tauri réel ; aucune structure factice n'est ajoutée uniquement pour créer le répertoire.
## Bootstrap backend
Les arguments `--cfgpath`, `--schemapath` et `--filemap=...` sont transmis tels quels à `ksp-config-lib`. Le profil Logging initial est le `default_profile` de `std.logging.json`.
Si cette configuration ne peut pas être utilisée, l'application doit rester démarrable pour permettre sa réparation : elle utilise alors un fallback Logging console/stderr en mémoire. Le fallback n'écrit aucun fichier de configuration et n'écrase aucune valeur utilisateur.
La commande Tauri `get_app_snapshot` expose un état sûr du bootstrap. Ses types TypeScript sont générés par `cargo test -p ksp-app-config-desk` sous `frontend/ts/bindings/`.
## Asset font du splash
La police bot3 de référence est `DOS_Amazigh.ttf`. Vérifier l'asset local contre l'empreinte documentée dans `frontend/fonts/README.md` avant la tranche de finition du splash.

View File

@@ -0,0 +1,31 @@
<!-- file: crates/ksp-app-config-desk/frontend/fonts/README.md -->
<!-- version: 1 -->
# Fonts du splash
Le gabarit de splash repris de `khadhroony-bot3` utilise la police locale suivante :
```text
DOS_Amazigh.ttf
font-family: Dos Amazigh
```
Source de référence auditée :
```text
kb-app-demo-desktop/frontend/fonts/DOS_Amazigh.ttf
```
Destination KSP attendue :
```text
crates/ksp-app-config-desk/frontend/fonts/DOS_Amazigh.ttf
```
Empreinte SHA-256 de l'asset bot3 audité :
```text
a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02
```
Le fichier est un asset tiers/historique du gabarit et doit être repris depuis la copie bot3 possédée par le projet. `splash.scss` basculera explicitement sur `Dos Amazigh` lorsque l'asset est présent dans le workspace ; le lifecycle visuel complet reste traité dans la tranche splash dédiée.

View File

@@ -0,0 +1,78 @@
// file: crates/ksp-app-config-desk/src/app_state.rs
// version: 1
//! Shared backend state owned by the Tauri application.
struct LoggingRuntimeState {
guard: ksp_logging_lib::LoggingGuard,
active_profile_id: std::option::Option<String>,
generation: u32,
fallback_active: bool,
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
}
/// Shared Config Desk application state managed by Tauri.
pub(crate) struct AppState {
config_management: ksp_config_lib::ConfigManagement,
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
}
impl AppState {
/// Initializes Config ownership, the Logging runtime and durable application state.
pub(crate) fn initialize(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
let config_management = crate::config_management(arguments);
let config_management = match config_management {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let logging_startup = crate::initialize_logging(&config_management);
let logging_startup = match logging_startup {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
config_management,
logging_runtime: std::sync::Mutex::new(LoggingRuntimeState {
guard: logging_startup.guard,
active_profile_id: logging_startup.active_profile_id,
generation: 1,
fallback_active: logging_startup.fallback_active,
startup_diagnostic: logging_startup.startup_diagnostic,
}),
});
}
/// Builds a safe initial frontend snapshot without exposing resolved secret values or arbitrary error context.
pub(crate) fn snapshot(&self) -> ksp_core_lib::Result<crate::AppSnapshotDto> {
let document_count = self.config_management.engine().registry().descriptors().count();
let document_count = u32::try_from(document_count);
let document_count = match document_count {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Config registry contains too many descriptors for the desktop DTO")
.with_source(error),
);
},
};
let runtime = self.logging_runtime.lock();
let runtime = match runtime {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(ksp_core_lib::Error::new(
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
"Config Desk Logging runtime state lock is poisoned",
));
},
};
let _keep_guard_alive = &runtime.guard;
return std::result::Result::Ok(crate::AppSnapshotDto {
application_version: env!("CARGO_PKG_VERSION").to_owned(),
config_document_count: document_count,
active_logging_profile: runtime.active_profile_id.clone(),
logging_generation: runtime.generation,
fallback_logging_active: runtime.fallback_active,
startup_diagnostic: runtime.startup_diagnostic.clone(),
});
}
}

View File

@@ -0,0 +1,102 @@
// file: crates/ksp-app-config-desk/src/bootstrap.rs
// version: 1
//! Config and Logging bootstrap for the desktop application.
pub(crate) struct LoggingStartup {
pub(crate) guard: ksp_logging_lib::LoggingGuard,
pub(crate) active_profile_id: std::option::Option<String>,
pub(crate) fallback_active: bool,
pub(crate) startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
}
pub(crate) fn config_management(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_args(arguments);
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let registry = ksp_config_lib::ConfigFileRegistry::from_args(arguments);
let registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(engine));
}
pub(crate) fn initialize_logging(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<LoggingStartup> {
let environment = ksp_config_lib::ConfigEnvironment::load();
let environment = match environment {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return initialize_fallback_logging(error),
};
let resolved = management.engine().load_resolved_logging_config(std::option::Option::None, &environment);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return initialize_fallback_logging(error),
};
let active_profile_id = resolved.profile_id().to_owned();
let settings = resolved.into_settings();
let guard = ksp_logging_lib::initialize(&settings);
return match guard {
std::result::Result::Ok(guard) => {
ksp_logging_lib::info!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
active_profile = active_profile_id.as_str(),
"initialized Config Desk logging from managed configuration"
);
std::result::Result::Ok(LoggingStartup {
guard,
active_profile_id: std::option::Option::Some(active_profile_id),
fallback_active: false,
startup_diagnostic: std::option::Option::None,
})
},
std::result::Result::Err(error) => initialize_fallback_logging(error),
};
}
fn initialize_fallback_logging(initial_error: ksp_core_lib::Error) -> ksp_core_lib::Result<LoggingStartup> {
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
let settings = fallback_logging_settings();
let guard = ksp_logging_lib::initialize(&settings);
let guard = match guard {
std::result::Result::Ok(value) => value,
std::result::Result::Err(fallback_error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED, "Cannot initialize Config Desk fallback Logging runtime")
.with_context("initial_error_domain", initial_error.code().domain())
.with_context("initial_error_code", initial_error.code().code())
.with_source(fallback_error),
);
},
};
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
error_domain = diagnostic.domain.as_str(),
error_code = diagnostic.code.as_str(),
"managed Logging configuration is unavailable; using transient in-memory fallback"
);
return std::result::Result::Ok(LoggingStartup {
guard,
active_profile_id: std::option::Option::None,
fallback_active: true,
startup_diagnostic: std::option::Option::Some(diagnostic),
});
}
pub(crate) fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings {
return ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::vec::Vec::new(),
);
}
#[cfg(test)]
#[path = "../unit_tests/bootstrap.rs"]
mod tests;

View File

@@ -0,0 +1,9 @@
// file: crates/ksp-app-config-desk/src/constants.rs
// version: 1
//! Application-owned tracing targets and domains.
/// Owning target for backend events emitted by Config Desk.
pub(crate) const TRACING_TARGET: &str = "ksp-app-config-desk";
/// Structured domain used while bootstrapping Config and Logging.
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";

View File

@@ -0,0 +1,54 @@
// file: crates/ksp-app-config-desk/src/dto_common.rs
// version: 1
//! Common Tauri DTOs shared by Config Desk commands.
use ts_rs::TS; // rust-rules: derive-import
/// Safe command error projection that never serializes arbitrary KSP error context or source values.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/dto_common/CommandErrorDto.ts")]
pub(crate) struct CommandErrorDto {
/// Stable KSP error domain.
pub(crate) domain: String,
/// Stable KSP error code within the domain.
pub(crate) code: String,
/// Human-readable error message without arbitrary context fields.
pub(crate) message: String,
}
impl CommandErrorDto {
/// Builds a bounded safe projection from a KSP error.
#[must_use]
pub(crate) fn from_error(error: &ksp_core_lib::Error) -> Self {
return Self {
domain: error.code().domain().to_owned(),
code: error.code().code().to_owned(),
message: error.message().to_owned(),
};
}
}
/// Initial application/runtime snapshot exposed to the frontend.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts")]
pub(crate) struct AppSnapshotDto {
/// Cargo application version.
pub(crate) application_version: String,
/// Number of files registered by the Config registry.
pub(crate) config_document_count: u32,
/// Active configured Logging profile, or `None` while the transient fallback runtime is active.
pub(crate) active_logging_profile: std::option::Option<String>,
/// Monotonic runtime generation, initialized to one after the first Logging runtime install.
pub(crate) logging_generation: u32,
/// Whether Config Desk had to install its transient in-memory Logging fallback.
pub(crate) fallback_logging_active: bool,
/// Safe startup diagnostic that caused fallback Logging, when applicable.
pub(crate) startup_diagnostic: std::option::Option<CommandErrorDto>,
}
#[cfg(test)]
#[path = "../unit_tests/dto_common.rs"]
mod tests;

View File

@@ -1,7 +1,13 @@
// file: crates/ksp-app-config-desk/src/errors.rs
// version: 1
// version: 2
//! Application-local error codes for the configuration desktop shell.
/// Tauri runtime assembly or execution failed.
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_runtime_failed");
/// Config Desk could not install the managed Logging runtime or its safe fallback.
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_bootstrap_failed");
/// Shared Config Desk application state is internally inconsistent.
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_invalid");
/// Shared Config Desk runtime state cannot be locked safely.
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_lock_failed");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/lib.rs
// version: 1
// version: 2
//! Tauri desktop application for managing and validating KSP configuration.
@@ -7,10 +7,24 @@
#![deny(unreachable_pub)]
#![warn(missing_docs)]
mod app_state;
mod bootstrap;
mod constants;
mod dto_common;
mod errors;
mod tauri;
/// Runs the KSP configuration desktop application.
pub use self::tauri::run;
pub(crate) use self::app_state::AppState;
pub(crate) use self::bootstrap::config_management;
pub(crate) use self::bootstrap::initialize_logging;
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
pub(crate) use self::constants::TRACING_TARGET;
pub(crate) use self::dto_common::AppSnapshotDto;
pub(crate) use self::dto_common::CommandErrorDto;
pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID;
pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/tauri.rs
// version: 2
// version: 3
//! Tauri runtime assembly for the KSP configuration desktop application.
@@ -7,9 +7,16 @@ use tauri::Manager; // rust-rules: trait-import
/// Runs the configuration desktop application.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run(_arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
let app_state = crate::AppState::initialize(arguments);
let app_state = match app_state {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut builder = tauri::Builder::default();
builder = configure_state(builder, app_state);
builder = configure_plugins(builder);
builder = configure_commands(builder);
builder = configure_setup(builder);
let run_result = builder.run(tauri::generate_context!());
return match run_result {
@@ -21,11 +28,19 @@ pub fn run(_arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
};
}
fn configure_state(builder: tauri::Builder<tauri::Wry>, app_state: crate::AppState) -> tauri::Builder<tauri::Wry> {
return builder.manage(app_state);
}
fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
let tracing_plugin = tauri_plugin_tracing::Builder::new().build::<tauri::Wry>();
return builder.plugin(tracing_plugin);
}
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.invoke_handler(tauri::generate_handler![get_app_snapshot]);
}
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
return builder.setup(|app| {
if app.get_webview_window("splash").is_none() {
@@ -37,3 +52,12 @@ fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri:
return std::result::Result::Ok(());
});
}
#[tauri::command]
fn get_app_snapshot(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::AppSnapshotDto, crate::CommandErrorDto> {
let snapshot = state.snapshot();
return match snapshot {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
};
}

View File

@@ -0,0 +1,17 @@
// file: crates/ksp-app-config-desk/unit_tests/bootstrap.rs
// version: 1
#[test]
fn fallback_logging_is_console_only_and_bounded_to_info() {
let settings = super::fallback_logging_settings();
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
assert_eq!(settings.span_events(), ksp_logging_lib::SpanEvents::Off);
assert!(settings.files().is_empty());
let console = settings.console();
assert!(console.is_some());
if let std::option::Option::Some(console) = console {
assert!(console.enabled());
assert_eq!(console.output(), ksp_logging_lib::ConsoleOutput::Stderr);
assert_eq!(console.format(), ksp_logging_lib::LogFormat::Human);
}
}

View File

@@ -0,0 +1,11 @@
// file: crates/ksp-app-config-desk/unit_tests/dto_common.rs
// version: 1
#[test]
fn command_error_projection_excludes_context_values() {
let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failure"), "safe message").with_context("secret_canary", "must-not-be-exported");
let dto = super::CommandErrorDto::from_error(&error);
assert_eq!(dto.domain, "test");
assert_eq!(dto.code, "failure");
assert_eq!(dto.message, "safe message");
}