From 29660fd9f02d6e28a113942a43a5625aeb93a5a1 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 15 Aug 2026 19:58:18 +0200 Subject: [PATCH] v0.1.3-pre.004 --- Cargo.toml | 4 +- crates/ksp-logging-lib/README.md | 8 +- crates/ksp-logging-lib/TODO.md | 22 +- crates/ksp-logging-lib/USAGE.md | 114 ++++-- crates/ksp-logging-lib/src/lib.rs | 12 +- crates/ksp-logging-lib/src/runtime.rs | 49 ++- crates/ksp-logging-lib/src/settings.rs | 343 ++++++++++++++++-- crates/ksp-logging-lib/tests/public_api.rs | 31 +- crates/ksp-logging-lib/tests/runtime.rs | 28 +- crates/ksp-logging-lib/unit_tests/runtime.rs | 47 ++- crates/ksp-logging-lib/unit_tests/settings.rs | 218 ++++++++--- deltas/0.1.3/pre.004.md | 337 +++++++++++++++++ .../005-V0_1_3_CONFIG_FOUNDATION_PLAN.md | 95 +++-- 13 files changed, 1134 insertions(+), 174 deletions(-) create mode 100644 deltas/0.1.3/pre.004.md diff --git a/Cargo.toml b/Cargo.toml index a83bc40..299efcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 43 +# version: 44 [workspace] resolver = "3" members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.3-pre.3" +version = "0.1.3-pre.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-logging-lib/README.md b/crates/ksp-logging-lib/README.md index a06d1f7..e61efee 100644 --- a/crates/ksp-logging-lib/README.md +++ b/crates/ksp-logging-lib/README.md @@ -1,5 +1,5 @@ - + # ksp-logging-lib @@ -11,11 +11,13 @@ La crate possède : - les cinq niveaux KSP `error`, `warn`, `info`, `debug` et `trace` ; - les macros d'événements et de spans qui préservent le callsite du consommateur ; -- `LoggingSettings` et les settings console/fichier indépendants de Config ; +- `LoggingSettings`, la console explicite et les settings fichier indépendants de Config ; +- les formats publics `Human/Compact/Pretty/Json` et les filtres par output (`level`, targets KSP et domains) ; +- zéro, un ou plusieurs descriptors fichier identifiés par `output_id` unique ; - l'installation unique du subscriber global ; - le hot reload via `reinitialize` sans second subscriber global ; - le takeover des logs : les targets externes sont silencieux par défaut ; -- les sorties console et fichier non bloquantes ; +- le runtime non bloquant console/fichier existant, avec activation multi-sink/routing enrichie finalisée dans la tranche runtime dédiée ; - les `WorkerGuard`, compteurs de lignes abandonnées, rotation fichier et stripping ANSI ; - l'instrumentation de scopes synchrones et de `Future` async. diff --git a/crates/ksp-logging-lib/TODO.md b/crates/ksp-logging-lib/TODO.md index 6a338ab..1b2f8a2 100644 --- a/crates/ksp-logging-lib/TODO.md +++ b/crates/ksp-logging-lib/TODO.md @@ -1,22 +1,26 @@ - + # TODO ksp-logging-lib -## À fermer avant la stable 0.1.2 +## À fermer pendant `0.1.3` -- exécuter les validations Cargo complètes de `pre.006`, y compris les tests Tokio current-thread/multi-thread ; -- vérifier que `cargo tree -p ksp-logging-lib -e normal` ne contient pas Tokio et que Tokio apparaît uniquement dans le graphe dev attendu ; -- refaire l'audit final du graphe/features et de l'ownership de la stack tracing ; -- après validation de la prerelease finale, préparer `rel.001`, publier `workspace.package.version = "0.1.2"` et taguer `v0.1.2` conformément aux règles de release. +`0.1.3-pre.004` expose désormais le modèle public multi-output. La tranche runtime suivante doit encore : + +- activer simultanément zéro, un ou plusieurs fichiers ; +- appliquer réellement `OutputFilter` par sink sur niveau, target et domain ; +- appliquer `LogFormat::{Human, Compact, Pretty, Json}` par sink ; +- appliquer l'ANSI console configurable tout en gardant les fichiers sans séquences ANSI ; +- généraliser les writers/guards et compteurs de lignes abandonnées par output sans perdre la vue agrégée utile ; +- conserver le subscriber global unique, le takeover KSP et le hot reload transactionnel. + +Tant que cette tranche runtime n'est pas livrée, `initialize/reinitialize` refusent explicitement les capacités du nouveau contrat qu'ils ne savent pas encore appliquer. ## Capacités différées -Ces éléments ne font pas partie du contrat `0.1.2` et ne doivent être ajoutés qu'après besoin concret : +Ces éléments ne sont pas requis par la fondation Config `0.1.3` : -- plusieurs routes fichier indépendantes ; - rotation par taille, rétention/compression et symlink `latest` ; -- formats JSON ou autres formats structurés alternatifs ; - OpenTelemetry/export réseau ; - watcher de fichiers de configuration, qui appartient à Config ou à une couche supérieure ; - benchmark/profiling de précision destiné aux chemins de trading sensibles à la latence. diff --git a/crates/ksp-logging-lib/USAGE.md b/crates/ksp-logging-lib/USAGE.md index dd0ff98..76699f2 100644 --- a/crates/ksp-logging-lib/USAGE.md +++ b/crates/ksp-logging-lib/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de ksp-logging-lib @@ -21,20 +21,57 @@ ksp_logging_lib::trace!( Les champs `domain`, `component`, `operation` et autres champs structurés sont ajoutés par le caller lorsqu'ils sont utiles ; ils ne remplacent pas le target propriétaire. -## Initialisation +## Contrat des outputs -`initialize` installe le subscriber global KSP une seule fois et retourne le `LoggingGuard` qui doit rester vivant pendant la durée du runtime : +`LoggingSettings` possède : + +- le `default_filter` global de takeover KSP ; +- les `TargetFilter` globaux ; +- la politique `SpanEvents` ; +- une console déclarée optionnellement ; +- zéro, un ou plusieurs `FileSettings`. + +Chaque output possède un `OutputFilter` indépendant. Son niveau, ses targets et ses domains s'appliquent **en plus** de la politique globale. `*` signifie « tous » dans la dimension concernée. + +Les formats publics sont : + +```text +Human +Compact +Pretty +Json +``` + +Une sortie fichier possède un `output_id` stable et unique dans `LoggingSettings`. Cet identifiant appartient au runtime Logging et ne doit pas être confondu avec les `file_id` utilisés par `ksp-config-lib` pour identifier ses documents. + +Les fichiers persistants interdisent `ansi = true`. + +## Compatibilité runtime de `pre.004` + +`0.1.3-pre.004` étend d'abord le **contrat public**. Le runtime multi-sink/routing est livré séparément en `pre.005`. + +Jusqu'à cette tranche suivante, `initialize`/`reinitialize` refusent explicitement toute capacité nouvellement représentée qu'ils ne savent pas encore appliquer : plusieurs fichiers actifs, format non `Human`, ANSI console ou filtre propre à un output. Ces valeurs ne sont donc jamais acceptées puis ignorées silencieusement. + +Les helpers `ConsoleSettings::stdout()` et `ConsoleSettings::stderr()` construisent une console compatible avec le runtime actuel : activée, `Human`, sans ANSI et sans filtre supplémentaire par rapport au takeover global. + +## Initialisation compatible avec le runtime actuel ```rust +let file = ksp_logging_lib::FileSettings::new( + "file.worker", + true, + "logs", + "worker.log", + ksp_logging_lib::FileRotation::Daily, + ksp_logging_lib::LogFormat::Human, + ksp_logging_lib::OutputFilter::unrestricted(), +); + let settings = ksp_logging_lib::LoggingSettings::new( ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::NewAndClose, std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()), - std::option::Option::Some(ksp_logging_lib::FileSettings::new( - "logs", - "worker.log", - ksp_logging_lib::FileRotation::Daily, - )), + std::vec![file], ); let initialize_result = ksp_logging_lib::initialize(&settings); @@ -44,18 +81,61 @@ let mut logging_guard = match initialize_result { }; ``` -Une configuration sans console ni fichier est valide et installe une infrastructure initialement silencieuse qui pourra être activée plus tard par hot reload. +Une configuration sans output actif est valide et installe une infrastructure initialement silencieuse qui pourra être activée plus tard par hot reload. + +## Construction d'un contrat multi-output + +Le contrat public peut déjà représenter la future configuration Config complète : + +```rust +let console = ksp_logging_lib::ConsoleSettings::new( + true, + ksp_logging_lib::ConsoleOutput::Stderr, + true, + ksp_logging_lib::LogFormat::Compact, + ksp_logging_lib::OutputFilter::new( + ksp_logging_lib::LogFilterLevel::Debug, + std::vec!["*".to_string()], + std::vec!["*".to_string()], + ), +); + +let error_file = ksp_logging_lib::FileSettings::new( + "file.config.error", + true, + "logs/config", + "error.jsonl", + ksp_logging_lib::FileRotation::Daily, + ksp_logging_lib::LogFormat::Json, + ksp_logging_lib::OutputFilter::new( + ksp_logging_lib::LogFilterLevel::Error, + std::vec!["ksp-config-lib".to_string()], + std::vec!["config".to_string()], + ), +); + +let settings = ksp_logging_lib::LoggingSettings::new( + ksp_logging_lib::LogFilterLevel::Warn, + ksp_logging_lib::SpanEvents::NewAndClose, + std::option::Option::Some(console), + std::vec![error_file], +); + +let validation = settings.validate(); +``` + +`validate()` vérifie le contrat structurel. L'activation runtime de ce routage enrichi appartient à `pre.005`. ## Hot reload -Une nouvelle configuration peut être appliquée sans redémarrer le processus ou le worker : +Une configuration compatible avec les capacités runtime actives peut être appliquée sans redémarrer le processus ou le worker : ```rust let debug_settings = ksp_logging_lib::LoggingSettings::new( ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::NewAndClose, std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()), - std::option::Option::None, + std::vec::Vec::new(), ) .with_target_filter(ksp_logging_lib::TargetFilter::new( "ksp-store-lib", @@ -104,7 +184,7 @@ La future instrumentée entre/sort du span pendant ses polls et lors de son `Dro ## Lignes abandonnées -Les sorties utilisent des queues lossy afin de ne pas appliquer de backpressure au hot path. Les pertes restent observables : +Les sorties utilisent des queues lossy afin de ne pas appliquer de backpressure au hot path. Les compteurs restent encore agrégés console/fichier pendant `pre.004` ; leur généralisation par output appartient au runtime multi-sink : ```rust let dropped = logging_guard.dropped_lines(); @@ -119,12 +199,4 @@ ksp_logging_lib::warn!( ## Instrumentation async et executor -`instrument(span, future)` accepte une `Future` standard et ne dépend d'aucun executor particulier : - -```rust -let span = ksp_logging_lib::trace_span!(target: LOGGING_TARGET, "load_transactions"); -let result = ksp_logging_lib::instrument(span, async_operation()).await; -``` - -La crate ne requiert pas Tokio en production. Tokio n'est présent qu'en `dev-dependency` pour valider la surface sur un executor réel, y compris après plusieurs suspensions et sur un runtime multi-thread. Un consumer peut donc utiliser l'executor adapté à son propre contexte sans que Logging lui en impose un. - +`instrument(span, future)` accepte une `Future` standard et ne dépend d'aucun executor particulier. Tokio n'est présent qu'en `dev-dependency` pour valider la surface sur un executor réel, y compris après plusieurs suspensions et sur un runtime multi-thread. diff --git a/crates/ksp-logging-lib/src/lib.rs b/crates/ksp-logging-lib/src/lib.rs index b09d0d2..574db1b 100644 --- a/crates/ksp-logging-lib/src/lib.rs +++ b/crates/ksp-logging-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/src/lib.rs -// version: 4 +// version: 5 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -7,9 +7,9 @@ //! KSP-owned logging and tracing facade. //! //! This crate owns the KSP runtime logging contract. Behavioral KSP crates emit events and spans through this facade rather than depending directly on the -//! `tracing` stack. `0.1.2-pre.005` owns the single global subscriber, KSP takeover filtering, hot reload, non-blocking console/file outputs, rolling file -//! appenders, ANSI stripping, dropped-line counters and the worker guards required to flush active queues. The integration surface is hardened by -//! deterministic saturation, concurrent reload and ownership audits before final release validation. +//! `tracing` stack. The crate owns the single global subscriber, KSP takeover filtering, hot reload and non-blocking outputs. `0.1.3-pre.004` extends +//! the public settings contract with explicit console properties, multiple file descriptors, per-output level/target/domain filters and selectable formats; +//! runtime activation of the newly represented multi-output routing is completed separately so unsupported capabilities are never ignored silently. mod error; mod macros; @@ -44,8 +44,12 @@ pub use self::settings::FileRotation; pub use self::settings::FileSettings; /// Runtime filter level used by KSP logging settings. pub use self::settings::LogFilterLevel; +/// Output format requested for one Logging sink. +pub use self::settings::LogFormat; /// Complete runtime settings consumed by Logging initialization and reload. pub use self::settings::LoggingSettings; +/// Per-output routing filter applied in addition to the global KSP takeover policy. +pub use self::settings::OutputFilter; /// Lifecycle events emitted for spans by the formatted subscriber. pub use self::settings::SpanEvents; /// Per-target filter override owned by Logging. diff --git a/crates/ksp-logging-lib/src/runtime.rs b/crates/ksp-logging-lib/src/runtime.rs index c21fa9e..85979bf 100644 --- a/crates/ksp-logging-lib/src/runtime.rs +++ b/crates/ksp-logging-lib/src/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/src/runtime.rs -// version: 8 +// version: 9 use tracing_subscriber::Layer; // rust-rules: trait-import use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import @@ -165,17 +165,27 @@ fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result bool { + return console.enabled(); + }); + let enabled_file = settings.files().iter().find(|file| -> bool { + return file.enabled(); + }); + if enabled_console.is_none() && enabled_file.is_none() { return std::result::Result::Ok(PreparedRuntime { layers: RuntimeLayers::new(), outputs: RuntimeOutputs::default() }); } - let prepared_file = match settings.file() { + let prepared_file = match enabled_file { std::option::Option::Some(file) => match build_file_output(file, settings) { std::result::Result::Ok(output) => std::option::Option::Some(output), std::result::Result::Err(error) => return std::result::Result::Err(error), }, std::option::Option::None => std::option::Option::None, }; - let prepared_console = settings.console().map(|console| -> PreparedOutput { + let prepared_console = enabled_console.map(|console| -> PreparedOutput { return build_console_output(console, settings); }); let mut output_layers = RuntimeLayers::new(); @@ -193,6 +203,37 @@ fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result ksp_core_lib::Result<()> { + let enabled_file_count = settings + .files() + .iter() + .filter(|file| -> bool { + return file.enabled(); + }) + .count(); + if enabled_file_count > 1 { + return runtime_capability_error("multiple enabled file outputs require the multi-sink runtime"); + } + if let std::option::Option::Some(console) = settings.console() + && console.enabled() + && (console.ansi() || console.format() != crate::LogFormat::Human || !console.filter().is_unrestricted()) + { + return runtime_capability_error("console ANSI, selectable formats and per-output routing require the multi-sink runtime"); + } + for file in settings.files() { + if file.enabled() && (file.format() != crate::LogFormat::Human || !file.filter().is_unrestricted()) { + return runtime_capability_error("file formats and per-output routing require the multi-sink runtime"); + } + } + return std::result::Result::Ok(()); +} + +fn runtime_capability_error(message: &str) -> ksp_core_lib::Result<()> { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("runtime_contract", "single-output-compatibility"), + ); +} + fn build_console_output(console: &crate::ConsoleSettings, settings: &crate::LoggingSettings) -> PreparedOutput { return match console.output() { crate::ConsoleOutput::Stdout => build_non_blocking_output(std::io::stdout(), "ksp-logging-console", settings, true), diff --git a/crates/ksp-logging-lib/src/settings.rs b/crates/ksp-logging-lib/src/settings.rs index e7db7af..c29e4ff 100644 --- a/crates/ksp-logging-lib/src/settings.rs +++ b/crates/ksp-logging-lib/src/settings.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/src/settings.rs -// version: 2 +// version: 3 /// Runtime filter level used by KSP logging settings. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -18,7 +18,7 @@ pub enum LogFilterLevel { Trace, } -/// Per-target filter override owned by Logging. +/// Per-target filter override owned by Logging for the global KSP takeover policy. #[derive(Clone, Debug, Eq, PartialEq)] pub struct TargetFilter { target_prefix: std::string::String, @@ -56,6 +56,74 @@ pub enum SpanEvents { Full, } +/// Output format requested for one Logging sink. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum LogFormat { + /// Standard human-readable formatter with metadata. + Human, + /// Compact human-readable formatter. + Compact, + /// Expanded pretty human-readable formatter. + Pretty, + /// Structured JSON formatter. + Json, +} + +/// Per-output routing filter applied in addition to the global KSP takeover policy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutputFilter { + level: crate::LogFilterLevel, + targets: std::vec::Vec, + domains: std::vec::Vec, +} + +impl OutputFilter { + /// Creates an explicit per-output filter. + /// + /// `targets` contains KSP target prefixes or the single wildcard `*`. `domains` contains domain prefixes or the single wildcard `*`. + #[must_use] + pub fn new(level: crate::LogFilterLevel, targets: std::vec::Vec, domains: std::vec::Vec) -> Self { + return Self { level, targets, domains }; + } + + /// Creates an unrestricted routing filter that does not further constrain the global KSP takeover policy. + #[must_use] + pub fn unrestricted() -> Self { + return Self::new(crate::LogFilterLevel::Trace, std::vec!["*".to_string()], std::vec!["*".to_string()]); + } + + /// Returns the maximum verbosity accepted by this output. + #[must_use] + pub const fn level(&self) -> crate::LogFilterLevel { + return self.level; + } + + /// Returns target selectors in declaration order. + #[must_use] + pub fn targets(&self) -> &[std::string::String] { + return self.targets.as_slice(); + } + + /// Returns domain selectors in declaration order. + #[must_use] + pub fn domains(&self) -> &[std::string::String] { + return self.domains.as_slice(); + } + + /// Returns whether this filter leaves routing entirely to the global takeover policy. + pub(crate) fn is_unrestricted(&self) -> bool { + let targets_all = match self.targets.as_slice() { + [selector] => selector == "*", + _ => false, + }; + let domains_all = match self.domains.as_slice() { + [selector] => selector == "*", + _ => false, + }; + return self.level == crate::LogFilterLevel::Trace && targets_all && domains_all; + } +} + /// Console stream selected for human-readable logs. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ConsoleOutput { @@ -66,22 +134,38 @@ pub enum ConsoleOutput { } /// Runtime settings for the optional console output. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ConsoleSettings { + enabled: bool, output: crate::ConsoleOutput, + ansi: bool, + format: crate::LogFormat, + filter: crate::OutputFilter, } impl ConsoleSettings { - /// Creates console settings targeting standard output. + /// Creates explicit console settings. #[must_use] - pub const fn stdout() -> Self { - return Self { output: crate::ConsoleOutput::Stdout }; + pub fn new(enabled: bool, output: crate::ConsoleOutput, ansi: bool, format: crate::LogFormat, filter: crate::OutputFilter) -> Self { + return Self { enabled, output, ansi, format, filter }; } - /// Creates console settings targeting standard error. + /// Creates enabled standard-output settings compatible with the `0.1.2` runtime behavior. #[must_use] - pub const fn stderr() -> Self { - return Self { output: crate::ConsoleOutput::Stderr }; + pub fn stdout() -> Self { + return Self::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted()); + } + + /// Creates enabled standard-error settings compatible with the `0.1.2` runtime behavior. + #[must_use] + pub fn stderr() -> Self { + return Self::new(true, crate::ConsoleOutput::Stderr, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted()); + } + + /// Returns whether this console output is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; } /// Returns the selected console stream. @@ -89,9 +173,27 @@ impl ConsoleSettings { pub const fn output(&self) -> crate::ConsoleOutput { return self.output; } + + /// Returns whether ANSI formatting is requested for the console output. + #[must_use] + pub const fn ansi(&self) -> bool { + return self.ansi; + } + + /// Returns the requested console format. + #[must_use] + pub const fn format(&self) -> crate::LogFormat { + return self.format; + } + + /// Returns the routing filter associated with the console output. + #[must_use] + pub const fn filter(&self) -> &crate::OutputFilter { + return &self.filter; + } } -/// Rotation cadence for the optional file output. +/// Rotation cadence for a file output. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum FileRotation { /// Keeps a single non-rotating file. @@ -102,23 +204,62 @@ pub enum FileRotation { Daily, } -/// Runtime settings for the optional file output. +/// Runtime settings for one file output. #[derive(Clone, Debug, Eq, PartialEq)] pub struct FileSettings { + output_id: std::string::String, + enabled: bool, directory: std::path::PathBuf, file_name_prefix: std::string::String, rotation: crate::FileRotation, + format: crate::LogFormat, + ansi: bool, + filter: crate::OutputFilter, } impl FileSettings { - /// Creates file output settings. + /// Creates explicit file output settings. #[must_use] pub fn new( + output_id: impl std::convert::Into, + enabled: bool, directory: impl std::convert::Into, file_name_prefix: impl std::convert::Into, rotation: crate::FileRotation, + format: crate::LogFormat, + filter: crate::OutputFilter, ) -> Self { - return Self { directory: directory.into(), file_name_prefix: file_name_prefix.into(), rotation }; + return Self { + output_id: output_id.into(), + enabled, + directory: directory.into(), + file_name_prefix: file_name_prefix.into(), + rotation, + format, + ansi: false, + filter, + }; + } + + /// Sets whether ANSI formatting is requested and returns the updated settings. + /// + /// Persistent file outputs are required to keep this value `false`; [`crate::LoggingSettings::validate`] rejects `true`. + #[must_use] + pub fn with_ansi(mut self, ansi: bool) -> Self { + self.ansi = ansi; + return self; + } + + /// Returns the stable output identifier used for diagnostics and reload accounting. + #[must_use] + pub fn output_id(&self) -> &str { + return self.output_id.as_str(); + } + + /// Returns whether this file output is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; } /// Returns the directory containing log files. @@ -138,6 +279,24 @@ impl FileSettings { pub const fn rotation(&self) -> crate::FileRotation { return self.rotation; } + + /// Returns the requested file format. + #[must_use] + pub const fn format(&self) -> crate::LogFormat { + return self.format; + } + + /// Returns whether ANSI formatting was requested for this file output. + #[must_use] + pub const fn ansi(&self) -> bool { + return self.ansi; + } + + /// Returns the routing filter associated with this file output. + #[must_use] + pub const fn filter(&self) -> &crate::OutputFilter { + return &self.filter; + } } /// Complete runtime settings consumed by `ksp-logging-lib` initialization and reload. @@ -147,29 +306,29 @@ pub struct LoggingSettings { target_filters: std::vec::Vec, span_events: crate::SpanEvents, console: std::option::Option, - file: std::option::Option, + files: std::vec::Vec, } impl LoggingSettings { - /// Creates explicit Logging settings without any target override. + /// Creates explicit Logging settings without any global target override. #[must_use] pub fn new( default_filter: crate::LogFilterLevel, span_events: crate::SpanEvents, console: std::option::Option, - file: std::option::Option, + files: std::vec::Vec, ) -> Self { - return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, file }; + return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, files }; } - /// Adds one target-prefix override and returns the updated settings. + /// Adds one target-prefix override to the global KSP takeover policy and returns the updated settings. #[must_use] pub fn with_target_filter(mut self, target_filter: crate::TargetFilter) -> Self { self.target_filters.push(target_filter); return self; } - /// Returns the default level applied to KSP-owned targets. + /// Returns the default level applied to KSP-owned targets by the global takeover policy. #[must_use] pub const fn default_filter(&self) -> crate::LogFilterLevel { return self.default_filter; @@ -187,16 +346,16 @@ impl LoggingSettings { return self.span_events; } - /// Returns console settings when console output is enabled. + /// Returns console settings when the console output is declared. #[must_use] pub fn console(&self) -> std::option::Option<&crate::ConsoleSettings> { return self.console.as_ref(); } - /// Returns file settings when file output is enabled. + /// Returns all declared file outputs in declaration order. #[must_use] - pub fn file(&self) -> std::option::Option<&crate::FileSettings> { - return self.file.as_ref(); + pub fn files(&self) -> &[crate::FileSettings] { + return self.files.as_slice(); } /// Validates backend-independent invariants of the runtime settings. @@ -216,18 +375,142 @@ impl LoggingSettings { ); } } - if let std::option::Option::Some(file) = self.file.as_ref() - && file.file_name_prefix().is_empty() - { - return std::result::Result::Err( - ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file name prefix must not be empty") - .with_context("field", "file.file_name_prefix"), - ); + if let std::option::Option::Some(console) = self.console.as_ref() { + let validation_result = validate_output_filter(console.filter(), "console.filter"); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + for (index, file) in self.files.iter().enumerate() { + let output_id_validation = validate_output_id(file.output_id(), index); + if let std::result::Result::Err(error) = output_id_validation { + return std::result::Result::Err(error); + } + if file.directory().as_os_str().is_empty() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output directory must not be empty") + .with_context("field", format!("files[{index}].directory")) + .with_context("output_id", file.output_id()), + ); + } + if file.file_name_prefix().is_empty() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file name prefix must not be empty") + .with_context("field", format!("files[{index}].file_name_prefix")) + .with_context("output_id", file.output_id()), + ); + } + if file.ansi() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI sequences are not allowed in persistent file outputs") + .with_context("field", format!("files[{index}].ansi")) + .with_context("output_id", file.output_id()), + ); + } + let filter_validation = validate_output_filter(file.filter(), format!("files[{index}].filter").as_str()); + if let std::result::Result::Err(error) = filter_validation { + return std::result::Result::Err(error.with_context("output_id", file.output_id())); + } + for previous in &self.files[..index] { + if previous.output_id() == file.output_id() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifiers must be unique") + .with_context("field", format!("files[{index}].output_id")) + .with_context("output_id", file.output_id()), + ); + } + } } return std::result::Result::Ok(()); } } +fn validate_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> { + let mut previous_was_separator = true; + if output_id.is_empty() { + return invalid_output_id(output_id, index); + } + for byte in output_id.bytes() { + if byte == b'.' { + if previous_was_separator { + return invalid_output_id(output_id, index); + } + previous_was_separator = true; + continue; + } + if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-') { + return invalid_output_id(output_id, index); + } + previous_was_separator = false; + } + if previous_was_separator { + return invalid_output_id(output_id, index); + } + return std::result::Result::Ok(()); +} + +fn invalid_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifier is invalid") + .with_context("field", format!("files[{index}].output_id")) + .with_context("output_id", output_id), + ); +} + +fn validate_output_filter(filter: &crate::OutputFilter, field: &str) -> ksp_core_lib::Result<()> { + let target_validation = validate_selectors(filter.targets(), true, format!("{field}.targets").as_str()); + if let std::result::Result::Err(error) = target_validation { + return std::result::Result::Err(error); + } + let domain_validation = validate_selectors(filter.domains(), false, format!("{field}.domains").as_str()); + if let std::result::Result::Err(error) = domain_validation { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(()); +} + +fn validate_selectors(selectors: &[std::string::String], target_dimension: bool, field: &str) -> ksp_core_lib::Result<()> { + if selectors.is_empty() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector list must not be empty").with_context("field", field), + ); + } + if selectors.len() > 1 + && selectors.iter().any(|selector| -> bool { + return selector == "*"; + }) + { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "wildcard selector must be used alone").with_context("field", field), + ); + } + for (index, selector) in selectors.iter().enumerate() { + if selector.trim().is_empty() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector must not be empty") + .with_context("field", format!("{field}[{index}]")), + ); + } + if target_dimension && selector != "*" && !selector.starts_with("ksp-") { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output target selector must identify a KSP-owned target") + .with_context("field", format!("{field}[{index}]")) + .with_context("selector", selector), + ); + } + for previous in &selectors[..index] { + if previous == selector { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selectors must be unique") + .with_context("field", format!("{field}[{index}]")) + .with_context("selector", selector), + ); + } + } + } + return std::result::Result::Ok(()); +} + #[cfg(test)] #[path = "../unit_tests/settings.rs"] mod tests; diff --git a/crates/ksp-logging-lib/tests/public_api.rs b/crates/ksp-logging-lib/tests/public_api.rs index fe25f62..4e5d91c 100644 --- a/crates/ksp-logging-lib/tests/public_api.rs +++ b/crates/ksp-logging-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/tests/public_api.rs -// version: 4 +// version: 5 //! Integration tests for the public crate-root surface of `ksp-logging-lib`. @@ -7,16 +7,41 @@ const TEST_TARGET: &str = "ksp-logging-lib"; #[test] fn public_settings_surface_is_usable() { + let console_filter = + ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Debug, std::vec![TEST_TARGET.to_string()], std::vec!["logging".to_string()]); + let file_filter = ksp_logging_lib::OutputFilter::new( + ksp_logging_lib::LogFilterLevel::Error, + std::vec![TEST_TARGET.to_string()], + std::vec!["logging.runtime".to_string()], + ); let settings = ksp_logging_lib::LoggingSettings::new( ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::NewAndClose, - std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stdout()), - std::option::Option::Some(ksp_logging_lib::FileSettings::new("logs", "ksp", ksp_logging_lib::FileRotation::Daily)), + std::option::Option::Some(ksp_logging_lib::ConsoleSettings::new( + true, + ksp_logging_lib::ConsoleOutput::Stdout, + true, + ksp_logging_lib::LogFormat::Compact, + console_filter, + )), + std::vec![ksp_logging_lib::FileSettings::new( + "file.logging.error", + true, + "logs", + "error.jsonl", + ksp_logging_lib::FileRotation::Daily, + ksp_logging_lib::LogFormat::Json, + file_filter, + )], ) .with_target_filter(ksp_logging_lib::TargetFilter::new(TEST_TARGET, ksp_logging_lib::LogFilterLevel::Trace)); assert!(settings.validate().is_ok()); assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info); assert_eq!(settings.console().map(ksp_logging_lib::ConsoleSettings::output), std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stdout)); + assert_eq!(settings.console().map(ksp_logging_lib::ConsoleSettings::format), std::option::Option::Some(ksp_logging_lib::LogFormat::Compact)); + assert_eq!(settings.files().len(), 1); + assert_eq!(settings.files()[0].output_id(), "file.logging.error"); + assert_eq!(settings.files()[0].format(), ksp_logging_lib::LogFormat::Json); } #[test] diff --git a/crates/ksp-logging-lib/tests/runtime.rs b/crates/ksp-logging-lib/tests/runtime.rs index 9d016f8..45e1c49 100644 --- a/crates/ksp-logging-lib/tests/runtime.rs +++ b/crates/ksp-logging-lib/tests/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/tests/runtime.rs -// version: 4 +// version: 5 //! Integration tests for global initialization, takeover filtering, non-blocking outputs and hot reload. @@ -67,7 +67,7 @@ fn exercise_concurrent_reload(guard: &mut ksp_logging_lib::LoggingGuard, disable ksp_logging_lib::LogFilterLevel::Off, ksp_logging_lib::SpanEvents::Off, std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()), - std::option::Option::None, + std::vec::Vec::new(), ); let quiet_reload = ksp_logging_lib::reinitialize(guard, &quiet_console); assert!(quiet_reload.is_ok()); @@ -116,7 +116,7 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::Off, std::option::Option::None, - std::option::Option::None, + std::vec::Vec::new(), ); let initialize_result = ksp_logging_lib::initialize(&disabled); assert!(initialize_result.is_ok()); @@ -132,7 +132,7 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::NewAndClose, std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()), - std::option::Option::None, + std::vec::Vec::new(), ) .with_target_filter(ksp_logging_lib::TargetFilter::new(LOGGING_TARGET, ksp_logging_lib::LogFilterLevel::Trace)); let reload_result = ksp_logging_lib::reinitialize(&mut guard, &console_enabled); @@ -151,7 +151,15 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i ksp_logging_lib::LogFilterLevel::Error, ksp_logging_lib::SpanEvents::Full, std::option::Option::None, - std::option::Option::Some(ksp_logging_lib::FileSettings::new(blocked_directory.as_path(), "invalid", ksp_logging_lib::FileRotation::Daily)), + std::vec![ksp_logging_lib::FileSettings::new( + "file.invalid", + true, + blocked_directory.as_path(), + "invalid", + ksp_logging_lib::FileRotation::Daily, + ksp_logging_lib::LogFormat::Human, + ksp_logging_lib::OutputFilter::unrestricted(), + )], ); let failed_reload = ksp_logging_lib::reinitialize(&mut guard, &invalid_file); assert!(failed_reload.is_err()); @@ -169,7 +177,15 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i ksp_logging_lib::LogFilterLevel::Info, ksp_logging_lib::SpanEvents::Off, std::option::Option::None, - std::option::Option::Some(ksp_logging_lib::FileSettings::new(log_directory.as_path(), "runtime-test.log", ksp_logging_lib::FileRotation::Never)), + std::vec![ksp_logging_lib::FileSettings::new( + "file.runtime", + true, + log_directory.as_path(), + "runtime-test.log", + ksp_logging_lib::FileRotation::Never, + ksp_logging_lib::LogFormat::Human, + ksp_logging_lib::OutputFilter::unrestricted(), + )], ); let file_reload = ksp_logging_lib::reinitialize(&mut guard, &file_enabled); assert!(file_reload.is_ok()); diff --git a/crates/ksp-logging-lib/unit_tests/runtime.rs b/crates/ksp-logging-lib/unit_tests/runtime.rs index 4523947..aea9c29 100644 --- a/crates/ksp-logging-lib/unit_tests/runtime.rs +++ b/crates/ksp-logging-lib/unit_tests/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-logging-lib/unit_tests/runtime.rs -// version: 5 +// version: 6 #[test] fn level_mapping_covers_all_ksp_levels() { @@ -17,7 +17,7 @@ fn takeover_filter_silences_external_targets_and_applies_ksp_overrides() { crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(crate::ConsoleSettings::stdout()), - std::option::Option::None, + std::vec::Vec::new(), ) .with_target_filter(crate::TargetFilter::new("ksp-logging-lib", crate::LogFilterLevel::Trace)); let filter = super::build_target_filter(&settings); @@ -47,7 +47,7 @@ fn file_rotation_mapping_covers_supported_cadences() { #[test] fn disabled_runtime_has_no_layers_or_outputs() { - let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None); + let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new()); let result = super::prepare_runtime(&settings); assert!(result.is_ok()); let prepared = match result { @@ -65,7 +65,7 @@ fn console_runtime_composes_takeover_filter_before_formatter_and_owns_guard() { crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(crate::ConsoleSettings::stdout()), - std::option::Option::None, + std::vec::Vec::new(), ); let result = super::prepare_runtime(&settings); assert!(result.is_ok()); @@ -91,7 +91,7 @@ fn dropped_line_snapshots_add_saturating_by_sink() { #[test] fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() { - let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None) + let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new()) .with_target_filter(crate::TargetFilter::new("ksp-store-", crate::LogFilterLevel::Debug)) .with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace)) .with_target_filter(crate::TargetFilter::new("ksp-wallet-lib", crate::LogFilterLevel::Off)); @@ -102,6 +102,43 @@ fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() { assert!(!filter.would_enable("ksp-wallet-lib", &tracing::Level::ERROR)); } +#[test] +fn pre_multi_sink_runtime_rejects_new_capabilities_instead_of_ignoring_them() { + let routed_console = crate::ConsoleSettings::new( + true, + crate::ConsoleOutput::Stdout, + true, + crate::LogFormat::Compact, + crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["ksp-logging-lib".to_string()], std::vec!["logging".to_string()]), + ); + let routed_settings = + crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(routed_console), std::vec::Vec::new()); + assert!(routed_settings.validate().is_ok()); + assert!(super::prepare_runtime(&routed_settings).is_err()); + let first_file = crate::FileSettings::new( + "file.first", + true, + "logs", + "first.log", + crate::FileRotation::Never, + crate::LogFormat::Human, + crate::OutputFilter::unrestricted(), + ); + let second_file = crate::FileSettings::new( + "file.second", + true, + "logs", + "second.log", + crate::FileRotation::Never, + crate::LogFormat::Human, + crate::OutputFilter::unrestricted(), + ); + let multi_file_settings = + crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![first_file, second_file]); + assert!(multi_file_settings.validate().is_ok()); + assert!(super::prepare_runtime(&multi_file_settings).is_err()); +} + struct BlockingWriter { first_write: bool, started: std::sync::mpsc::SyncSender<()>, diff --git a/crates/ksp-logging-lib/unit_tests/settings.rs b/crates/ksp-logging-lib/unit_tests/settings.rs index 34a4d6a..a991bcb 100644 --- a/crates/ksp-logging-lib/unit_tests/settings.rs +++ b/crates/ksp-logging-lib/unit_tests/settings.rs @@ -1,5 +1,17 @@ // file: crates/ksp-logging-lib/unit_tests/settings.rs -// version: 1 +// version: 2 + +fn unrestricted_file(output_id: &str) -> crate::FileSettings { + return crate::FileSettings::new( + output_id, + true, + "logs", + format!("{output_id}.log"), + crate::FileRotation::Daily, + crate::LogFormat::Human, + crate::OutputFilter::unrestricted(), + ); +} #[test] fn level_variants_are_distinct() { @@ -10,6 +22,13 @@ fn level_variants_are_distinct() { assert_ne!(crate::LogFilterLevel::Debug, crate::LogFilterLevel::Trace); } +#[test] +fn format_variants_are_distinct() { + assert_ne!(crate::LogFormat::Human, crate::LogFormat::Compact); + assert_ne!(crate::LogFormat::Compact, crate::LogFormat::Pretty); + assert_ne!(crate::LogFormat::Pretty, crate::LogFormat::Json); +} + #[test] fn target_filter_preserves_prefix_and_level() { let filter = crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace); @@ -18,85 +37,188 @@ fn target_filter_preserves_prefix_and_level() { } #[test] -fn console_settings_select_requested_stream() { - assert_eq!(crate::ConsoleSettings::stdout().output(), crate::ConsoleOutput::Stdout); - assert_eq!(crate::ConsoleSettings::stderr().output(), crate::ConsoleOutput::Stderr); +fn output_filter_preserves_level_targets_and_domains() { + let filter = crate::OutputFilter::new( + crate::LogFilterLevel::Debug, + std::vec!["ksp-config-lib".to_string(), "ksp-logging-lib".to_string()], + std::vec!["config".to_string(), "logging.runtime".to_string()], + ); + assert_eq!(filter.level(), crate::LogFilterLevel::Debug); + assert_eq!(filter.targets().len(), 2); + assert_eq!(filter.targets()[0], "ksp-config-lib"); + assert_eq!(filter.targets()[1], "ksp-logging-lib"); + assert_eq!(filter.domains().len(), 2); + assert_eq!(filter.domains()[0], "config"); + assert_eq!(filter.domains()[1], "logging.runtime"); + assert!(!filter.is_unrestricted()); + assert!(crate::OutputFilter::unrestricted().is_unrestricted()); } #[test] -fn file_settings_preserve_values() { - let settings = crate::FileSettings::new("logs", "ksp", crate::FileRotation::Daily); - assert_eq!(settings.directory(), std::path::Path::new("logs")); - assert_eq!(settings.file_name_prefix(), "ksp"); +fn console_settings_preserve_enabled_stream_ansi_format_and_filter() { + let filter = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["config".to_string()]); + let settings = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stderr, true, crate::LogFormat::Compact, filter.clone()); + assert!(settings.enabled()); + assert_eq!(settings.output(), crate::ConsoleOutput::Stderr); + assert!(settings.ansi()); + assert_eq!(settings.format(), crate::LogFormat::Compact); + assert_eq!(settings.filter(), &filter); +} + +#[test] +fn compatibility_console_constructors_are_unrestricted_human_and_non_ansi() { + let stdout = crate::ConsoleSettings::stdout(); + let stderr = crate::ConsoleSettings::stderr(); + assert_eq!(stdout.output(), crate::ConsoleOutput::Stdout); + assert_eq!(stderr.output(), crate::ConsoleOutput::Stderr); + assert!(stdout.enabled()); + assert!(!stdout.ansi()); + assert_eq!(stdout.format(), crate::LogFormat::Human); + assert!(stdout.filter().is_unrestricted()); +} + +#[test] +fn file_settings_preserve_multi_output_contract() { + let filter = crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]); + let settings = + crate::FileSettings::new("file.config.error", true, "logs/config", "error.jsonl", crate::FileRotation::Daily, crate::LogFormat::Json, filter.clone()); + assert_eq!(settings.output_id(), "file.config.error"); + assert!(settings.enabled()); + assert_eq!(settings.directory(), std::path::Path::new("logs/config")); + assert_eq!(settings.file_name_prefix(), "error.jsonl"); assert_eq!(settings.rotation(), crate::FileRotation::Daily); + assert_eq!(settings.format(), crate::LogFormat::Json); + assert!(!settings.ansi()); + assert_eq!(settings.filter(), &filter); } #[test] -fn logging_settings_preserve_explicit_values() { +fn logging_settings_represent_multiple_file_outputs() { + let first = unrestricted_file("file.debug"); + let second = crate::FileSettings::new( + "file.config.error", + false, + "logs/config", + "error.jsonl", + crate::FileRotation::Hourly, + crate::LogFormat::Json, + crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]), + ); let settings = crate::LoggingSettings::new( crate::LogFilterLevel::Info, crate::SpanEvents::NewAndClose, std::option::Option::Some(crate::ConsoleSettings::stdout()), - std::option::Option::Some(crate::FileSettings::new("logs", "ksp", crate::FileRotation::Hourly)), + std::vec![first, second], ) .with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace)); assert_eq!(settings.default_filter(), crate::LogFilterLevel::Info); assert_eq!(settings.span_events(), crate::SpanEvents::NewAndClose); assert_eq!(settings.target_filters().len(), 1); - assert_eq!(settings.target_filters()[0].target_prefix(), "ksp-store-lib"); - assert_eq!(settings.console(), std::option::Option::Some(&crate::ConsoleSettings::stdout())); - assert_eq!(settings.file().map(crate::FileSettings::rotation), std::option::Option::Some(crate::FileRotation::Hourly)); + assert_eq!(settings.files().len(), 2); + assert_eq!(settings.files()[0].output_id(), "file.debug"); + assert_eq!(settings.files()[1].format(), crate::LogFormat::Json); + assert_eq!(settings.console().map(crate::ConsoleSettings::output), std::option::Option::Some(crate::ConsoleOutput::Stdout)); + assert!(settings.validate().is_ok()); } #[test] -fn validation_rejects_empty_target_prefix() { - let settings = crate::LoggingSettings::new( - crate::LogFilterLevel::Info, - crate::SpanEvents::Off, - std::option::Option::Some(crate::ConsoleSettings::stdout()), - std::option::Option::None, - ) - .with_target_filter(crate::TargetFilter::new("", crate::LogFilterLevel::Debug)); - assert!(settings.validate().is_err()); +fn validation_rejects_empty_or_external_global_target_prefix() { + let empty = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new()) + .with_target_filter(crate::TargetFilter::new("", crate::LogFilterLevel::Debug)); + let external = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new()) + .with_target_filter(crate::TargetFilter::new("sqlx", crate::LogFilterLevel::Debug)); + assert!(empty.validate().is_err()); + assert!(external.validate().is_err()); } #[test] -fn validation_rejects_external_target_prefix() { - let settings = crate::LoggingSettings::new( - crate::LogFilterLevel::Info, - crate::SpanEvents::Off, - std::option::Option::Some(crate::ConsoleSettings::stdout()), - std::option::Option::None, - ) - .with_target_filter(crate::TargetFilter::new("sqlx", crate::LogFilterLevel::Debug)); - assert!(settings.validate().is_err()); -} - -#[test] -fn validation_rejects_empty_file_prefix() { - let settings = crate::LoggingSettings::new( +fn validation_rejects_invalid_file_identity_and_duplicate_output_ids() { + let invalid_id = + crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![unrestricted_file("File.Debug")]); + let duplicate_id = crate::LoggingSettings::new( crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, - std::option::Option::Some(crate::FileSettings::new("logs", "", crate::FileRotation::Never)), + std::vec![unrestricted_file("file.debug"), unrestricted_file("file.debug")], ); - assert!(settings.validate().is_err()); + assert!(invalid_id.validate().is_err()); + assert!(duplicate_id.validate().is_err()); } #[test] -fn validation_accepts_ksp_outputs_and_filters() { - let settings = crate::LoggingSettings::new( - crate::LogFilterLevel::Info, - crate::SpanEvents::Full, - std::option::Option::Some(crate::ConsoleSettings::stderr()), - std::option::Option::Some(crate::FileSettings::new("logs", "worker", crate::FileRotation::Daily)), +fn validation_rejects_empty_file_paths_and_persistent_ansi() { + let empty_directory = crate::FileSettings::new( + "file.empty.dir", + true, + "", + "debug.log", + crate::FileRotation::Never, + crate::LogFormat::Human, + crate::OutputFilter::unrestricted(), + ); + let empty_prefix = crate::FileSettings::new( + "file.empty.prefix", + true, + "logs", + "", + crate::FileRotation::Never, + crate::LogFormat::Human, + crate::OutputFilter::unrestricted(), + ); + let ansi_file = crate::FileSettings::new( + "file.ansi", + true, + "logs", + "ansi.log", + crate::FileRotation::Never, + crate::LogFormat::Human, + crate::OutputFilter::unrestricted(), ) - .with_target_filter(crate::TargetFilter::new("ksp-worker-", crate::LogFilterLevel::Debug)); - assert!(settings.validate().is_ok()); + .with_ansi(true); + assert!( + crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_directory]) + .validate() + .is_err() + ); + assert!( + crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_prefix]) + .validate() + .is_err() + ); + assert!( + crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![ansi_file]) + .validate() + .is_err() + ); } #[test] -fn settings_allow_logging_to_be_disabled() { - let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Off, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None); +fn validation_rejects_invalid_output_selectors() { + let external_target = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["sqlx".to_string()], std::vec!["*".to_string()]); + let wildcard_mix = + crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string(), "ksp-config-lib".to_string()], std::vec!["*".to_string()]); + let duplicate_domain = + crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["config".to_string(), "config".to_string()]); + for filter in [external_target, wildcard_mix, duplicate_domain] { + let console = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, filter); + let settings = + crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec::Vec::new()); + assert!(settings.validate().is_err()); + } +} + +#[test] +fn settings_allow_all_outputs_to_be_disabled() { + let console = crate::ConsoleSettings::new(false, crate::ConsoleOutput::Stderr, true, crate::LogFormat::Pretty, crate::OutputFilter::unrestricted()); + let file = crate::FileSettings::new( + "file.disabled", + false, + "logs", + "disabled.jsonl", + crate::FileRotation::Daily, + crate::LogFormat::Json, + crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]), + ); + let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Off, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec![file]); assert!(settings.validate().is_ok()); } diff --git a/deltas/0.1.3/pre.004.md b/deltas/0.1.3/pre.004.md new file mode 100644 index 0000000..c9b1228 --- /dev/null +++ b/deltas/0.1.3/pre.004.md @@ -0,0 +1,337 @@ + + + +# Delta 0.1.3-pre.004 + +## Base requise + +Livraison précédente validée : + +```text +0.1.3-pre.003 +``` + +Version technique de cette base : + +```text +workspace.package.version = "0.1.3-pre.3" +Cargo.toml header version = 43 +``` + +Validations utilisateur de `pre.003` : + +```text +cargo fmt --all OK +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test --workspace OK +``` + +Les tests Config livrés en `pre.003` réussissent : 21 tests unitaires et 4 tests d'API publique. + +## Objet de pre.004 + +Cette tranche complète uniquement les **contrats/settings publics** de `ksp-logging-lib` nécessaires au futur `config/std.logging.json`. + +Elle ne livre pas encore le runtime multi-sink complet. Le but est de stabiliser ce que Logging sait représenter avant de figer le schema JSON Config. + +## Modèle public ajouté + +### `LogFormat` + +Formats publics représentables : + +```text +Human +Compact +Pretty +Json +``` + +L'activation backend de tous ces formats appartient à la tranche runtime suivante. + +### `OutputFilter` + +Chaque sink peut maintenant déclarer un filtre propre contenant : + +```text +level +targets[] +domains[] +``` + +Les selectors `targets` sont des préfixes de targets KSP ou le wildcard `*`. + +Les selectors `domains` sont des préfixes de domain ou le wildcard `*`. + +Le wildcard doit être utilisé seul dans sa dimension. Les listes vides, selectors vides, doublons et targets externes à KSP sont refusés par la validation publique. + +`OutputFilter::unrestricted()` représente l'absence de restriction supplémentaire par rapport au takeover global : + +```text +level = Trace +targets = ["*"] +domains = ["*"] +``` + +## Console explicite + +`ConsoleSettings` représente désormais explicitement : + +```text +enabled +output = stdout | stderr +ansi +format +filter +``` + +Les helpers : + +```text +ConsoleSettings::stdout() +ConsoleSettings::stderr() +``` + +restent disponibles et produisent un contrat compatible avec le runtime `0.1.2` : console activée, format `Human`, ANSI désactivé et `OutputFilter::unrestricted()`. + +## Plusieurs outputs fichier + +`LoggingSettings` ne contient plus un unique : + +```text +Option +``` + +mais : + +```text +Vec +``` + +Chaque `FileSettings` porte : + +```text +output_id +enabled +directory +file_name_prefix +rotation +format +ansi +filter +``` + +`output_id` est une identité Logging stable, unique parmi les fichiers d'un même `LoggingSettings`. + +Il ne doit pas être confondu avec le `file_id` de `ksp-config-lib` : + +```text +file_id -> identité d'un document/schema Config +output_id -> identité d'une sortie Logging +``` + +La syntaxe initiale d'un `output_id` accepte l'ASCII minuscule, chiffres, `_`, `-` et des segments séparés par `.` sans segment vide. + +Exemples : + +```text +file.debug +file.config.error +file.ksp-config-lib.error +``` + +## ANSI fichier + +Une sortie persistante avec : + +```text +ansi = true +``` + +est refusée par `LoggingSettings::validate()`. + +Le contrat KSP conserve donc l'invariant qu'un fichier de logs ne persiste pas de séquences ANSI. + +## Deux niveaux de filtrage conservés + +Le nouveau filtre par sink ne remplace pas la politique existante. + +`LoggingSettings` conserve : + +```text +default_filter +target_filters[] +``` + +qui définissent le takeover global KSP. + +Puis chaque console/fichier possède son `OutputFilter`, appliqué conceptuellement **en plus** du takeover global. + +Cette séparation permet de conserver les overrides généraux tout en préparant un routing indépendant par sink, target, domain et niveau. + +## Compatibilité runtime transitoire + +Cette tranche ne doit jamais accepter silencieusement un contrat que le runtime `0.1.2` ne sait pas encore appliquer. + +Après la validation structurelle, `initialize/reinitialize` refusent donc temporairement : + +- plusieurs fichiers **actifs** simultanément ; +- ANSI console ; +- format console différent de `Human` ; +- format fichier différent de `Human` ; +- filtre propre à un output différent de `OutputFilter::unrestricted()`. + +Les outputs désactivés peuvent déjà porter leur future configuration sans influencer le runtime actif. + +Le refus utilise actuellement `logging.invalid_settings` avec un contexte `runtime_contract = single-output-compatibility`. + +Ce garde-fou transitoire sera supprimé/remplacé lorsque `pre.005` implémentera réellement les capacités représentées. + +## Lifecycle Logging préservé + +Cette tranche ne modifie pas les responsabilités suivantes : + +```text +initialize(...) +reinitialize(...) +LoggingGuard +subscriber global unique +takeover KSP +hot reload transactionnel +writers non bloquants +DroppedLines agrégé console/fichier +``` + +`DroppedLines` reste temporairement agrégé par type de sink ; sa généralisation éventuelle par `output_id` appartient à `pre.005`. + +## Frontière Config préservée + +Aucune dépendance inverse n'est introduite : + +```text +ksp-logging-lib -X-> ksp-config-lib +``` + +`ksp-logging-lib` ne lit toujours aucun fichier JSON, aucun profil, aucun `.env` et aucune variable applicative. + +Le futur `ksp-config-lib` construira ces contrats publics après résolution de `std.logging.json`. + +## Dépendances + +Aucune dépendance externe ou KSP supplémentaire n'est ajoutée. + +Le graphe direct de `ksp-config-lib` reste inchangé dans cette tranche. + +## Tests modifiés/ajoutés + +Les tests settings couvrent notamment : + +- les quatre formats ; +- niveau/targets/domains de `OutputFilter` ; +- wildcard unrestricted ; +- console enabled/output/ANSI/format/filter ; +- plusieurs `FileSettings` ; +- unicité et syntaxe des `output_id` ; +- fichiers sans ANSI ; +- selectors invalides, doublons et targets externes ; +- outputs déclarés mais désactivés. + +Les tests runtime couvrent également le garde-fou transitoire : + +- une console utilisant ANSI/format/routing enrichi est structurellement valide mais refusée par le backend courant ; +- plusieurs fichiers actifs sont structurellement valides mais refusés par le backend courant ; +- les contrats historiques compatibles continuent d'être préparés normalement. + +Le test d'intégration d'API publique construit un contrat avec console enrichie et fichier JSON filtré afin de vérifier que la nouvelle surface est bien accessible depuis le crate-root. + +## Documentation Logging + +`README.md`, `USAGE.md` et `TODO.md` sont alignés sur la séparation : + +```text +pre.004 = contrat public multi-output +pre.005 = activation runtime multi-sink/routing +``` + +## Version technique + +La prerelease devient : + +```text +workspace.package.version = "0.1.3-pre.4" +``` + +Le manifest racine devient : + +```text +# version: 44 +``` + +Le plan Config devient : + +```text + +``` + +## Fichiers ajoutés + +```text +deltas/0.1.3/pre.004.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-logging-lib/README.md +crates/ksp-logging-lib/TODO.md +crates/ksp-logging-lib/USAGE.md +crates/ksp-logging-lib/src/lib.rs +crates/ksp-logging-lib/src/runtime.rs +crates/ksp-logging-lib/src/settings.rs +crates/ksp-logging-lib/tests/public_api.rs +crates/ksp-logging-lib/tests/runtime.rs +crates/ksp-logging-lib/unit_tests/runtime.rs +crates/ksp-logging-lib/unit_tests/settings.rs +docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +``` + +## Hors scope confirmé + +Cette tranche n'implémente pas : + +- le runtime multi-sink complet ; +- le routing réel par target/domain de chaque sink ; +- les formatters Compact/Pretty/JSON runtime ; +- l'ANSI console runtime ; +- la généralisation complète des guards/drop counters par `output_id` ; +- `serde`, `serde_json` ou `jsonschema` ; +- `std.logging.json` ou son schema ; +- profils Config ; +- `.env` ou interpolation ; +- modification de `ksp-config-lib`. + +## Validations à exécuter par l'utilisateur + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test --workspace +cargo tree -p ksp-logging-lib +cargo tree -p ksp-logging-lib -d +cargo tree -p ksp-logging-lib -e features +``` + +Les commandes Cargo ne sont pas déclarées réussies dans le delta tant qu'elles n'ont pas été exécutées sur l'environnement utilisateur. + +## Suite + +Après validation de cette tranche : + +```text +0.1.3-pre.005 — ksp-logging-lib : runtime multi-sink + routing +``` + +Cette tranche suivante devra activer réellement les contrats stabilisés ici sans réintroduire Config dans Logging. diff --git a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md index 455d045..4f531fe 100644 --- a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +++ b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md @@ -1,11 +1,11 @@ - + # Plan `0.1.3` — Configuration foundation ## 1. Statut et objectif -Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001`, complété par `0.1.3-pre.001-fix.002`, puis regranularisé par `0.1.3-pre.001-fix.003`. `0.1.3-pre.002` ouvre maintenant le développement fonctionnel avec la seule fondation bootstrap de `ksp-config-lib`. +Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, et `pre.004` étend maintenant les contrats publics de `ksp-logging-lib` nécessaires au futur `std.logging.json` sans ouvrir encore le runtime multi-sink complet. La base auditée reste la release stable `v0.1.2`. @@ -230,25 +230,25 @@ La complétion Logging n'ouvre ni une nouvelle façade ni une dépendance invers ## 5. Matrice de responsabilités -| Responsabilité | Propriétaire | Consommateurs | Interdit | -|---------------------------------------------------------------------|--------------------------------------|--------------------------------------------|-----------------------------------------------------------| -| Définir les `file_id` connus et leur mapping par défaut | `ksp-config-lib` | bootstrap Config | noms physiques codés dans les consumers | -| Résoudre `file_id -> filename` | `ksp-config-lib` | session Config | référence inter-document par filename | -| Interpréter `--cfgpath`, `--schemapath` et overrides de mapping KSP | `ksp-config-lib` | applications qui transmettent argv/options | parser ces options différemment dans chaque binaire | -| Lire un document Config JSON | `ksp-config-lib` | crates/apps via API Config | lecture directe par consumer | -| Valider JSON Schema | `ksp-config-lib` | orchestration/management | validation divergente dans chaque crate | -| Résoudre globals/profils/compositions | `ksp-config-lib` | orchestration | résolution locale dans un binaire | -| Lire `KSP_*` / `KSPB_*` du processus | `ksp-config-lib` | crates/apps via API Config | `std::env::var*` applicatif hors Config | -| Lire `.env` | `ksp-config-lib` | crates/apps via API Config | loader dotenv direct hors Config | -| Résoudre `${...}` et fallback | `ksp-config-lib` | tous les consumers | interpolation locale dans les consumers | -| Classer sensibilité et construire une valeur sûre | `ksp-config-lib` | runtime/logging/diagnostics | redaction ad hoc dans chaque crate | -| Modifier/sauvegarder JSON Config | `ksp-config-lib` | management explicite | écriture directe par application | -| Créer/modifier/supprimer une entrée `.env` | `ksp-config-lib` | management explicite | édition directe par application | -| Modifier l'environnement externe du shell/systemd/parent | propriétaire externe de ce processus | Config le lit seulement | prétendre qu'un child process peut administrer son parent | -| Posséder les settings/sinks/routing Logging | `ksp-logging-lib` | Config construit les contrats publics | logique de routing dupliquée dans Config | -| Posséder `LoggingGuard` | orchestration/application | lifecycle Logging | singleton Config global | -| Initialiser/recharger Logging | orchestration | Config fournit settings/changement | Logging lisant Config | -| DTO/bindings Tauri | application Tauri | frontend | TS-RS automatique dans Config | +| Responsabilité | Propriétaire | Consommateurs | Interdit | +|---|---|---|---| +| Définir les `file_id` connus et leur mapping par défaut | `ksp-config-lib` | bootstrap Config | noms physiques codés dans les consumers | +| Résoudre `file_id -> filename` | `ksp-config-lib` | session Config | référence inter-document par filename | +| Interpréter `--cfgpath`, `--schemapath` et overrides de mapping KSP | `ksp-config-lib` | applications qui transmettent argv/options | parser ces options différemment dans chaque binaire | +| Lire un document Config JSON | `ksp-config-lib` | crates/apps via API Config | lecture directe par consumer | +| Valider JSON Schema | `ksp-config-lib` | orchestration/management | validation divergente dans chaque crate | +| Résoudre globals/profils/compositions | `ksp-config-lib` | orchestration | résolution locale dans un binaire | +| Lire `KSP_*` / `KSPB_*` du processus | `ksp-config-lib` | crates/apps via API Config | `std::env::var*` applicatif hors Config | +| Lire `.env` | `ksp-config-lib` | crates/apps via API Config | loader dotenv direct hors Config | +| Résoudre `${...}` et fallback | `ksp-config-lib` | tous les consumers | interpolation locale dans les consumers | +| Classer sensibilité et construire une valeur sûre | `ksp-config-lib` | runtime/logging/diagnostics | redaction ad hoc dans chaque crate | +| Modifier/sauvegarder JSON Config | `ksp-config-lib` | management explicite | écriture directe par application | +| Créer/modifier/supprimer une entrée `.env` | `ksp-config-lib` | management explicite | édition directe par application | +| Modifier l'environnement externe du shell/systemd/parent | propriétaire externe de ce processus | Config le lit seulement | prétendre qu'un child process peut administrer son parent | +| Posséder les settings/sinks/routing Logging | `ksp-logging-lib` | Config construit les contrats publics | logique de routing dupliquée dans Config | +| Posséder `LoggingGuard` | orchestration/application | lifecycle Logging | singleton Config global | +| Initialiser/recharger Logging | orchestration | Config fournit settings/changement | Logging lisant Config | +| DTO/bindings Tauri | application Tauri | frontend | TS-RS automatique dans Config | ## 6. Registre de fichiers, bootstrap, arborescence et nomenclature @@ -580,23 +580,23 @@ Décisions : ### 8.1 Écart `ksp-logging-lib 0.1.2` à fermer -| Capacité | `0.1.2` | Requise par `std.logging.json` | -|-----------------------------------|----------------:|-------------------------------------:| -| filtre global | oui | oui | -| overrides par target | oui | oui | -| lifecycle spans | oui | oui | -| console stdout/stderr | oui | oui | -| console enabled | via `Option` | oui explicite | -| console ANSI configurable | non | oui | -| format console configurable | non | oui | -| plusieurs fichiers | non | oui | -| rotation par fichier | un seul fichier | oui par sink | -| format par fichier | non | oui | -| filtre par sink/target | non | oui | -| filtre par sink/domain | non | oui | -| filtre par sink/niveau | non indépendant | oui | -| hot reload transactionnel | oui | à conserver | -| non-blocking/guards/drop counters | oui | à conserver et généraliser par sinks | +| Capacité | `0.1.2` | Requise par `std.logging.json` | +|---|---:|---:| +| filtre global | oui | oui | +| overrides par target | oui | oui | +| lifecycle spans | oui | oui | +| console stdout/stderr | oui | oui | +| console enabled | via `Option` | oui explicite | +| console ANSI configurable | non | oui | +| format console configurable | non | oui | +| plusieurs fichiers | non | oui | +| rotation par fichier | un seul fichier | oui par sink | +| format par fichier | non | oui | +| filtre par sink/target | non | oui | +| filtre par sink/domain | non | oui | +| filtre par sink/niveau | non indépendant | oui | +| hot reload transactionnel | oui | à conserver | +| non-blocking/guards/drop counters | oui | à conserver et généraliser par sinks | Ce tableau est un **gap identifié**, pas une invitation à déplacer Logging dans Config. La tranche qui le ferme modifie `ksp-logging-lib` uniquement dans son domaine propriétaire. @@ -1612,7 +1612,7 @@ Implémentation candidate livrée par `pre.003` : - l'unicité des `file_id`, les erreurs d'ID/mapping et les overrides config/schema sont couverts par tests unitaires et API publique ; - aucune dépendance externe, lecture JSON, validation schema ou `.env` n'est introduite par cette tranche. -La validation utilisateur de `pre.003` reste requise avant l'ouverture de `pre.004`. +Validation utilisateur acquise pour `pre.003` : `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test --workspace` sont propres, avec 21 tests unitaires Config et 4 tests d'API publique Config réussis. ### `0.1.3-pre.004` — `ksp-logging-lib` : modèle public multi-output @@ -1631,6 +1631,23 @@ Objectif unique : compléter les **contrats/settings publics** de Logging sans e Cette tranche ne doit pas être gonflée par le runtime multi-sink complet. Si même le modèle public dépasse le budget, il est scindé avant de poursuivre. +Implémentation candidate livrée par `pre.004` : + +- `LogFormat::{Human, Compact, Pretty, Json}` devient le contrat de format public ; +- `OutputFilter` porte un niveau et des listes explicites de selectors target/domain, avec `*` comme wildcard exclusif d'une dimension ; +- les selectors target restent bornés aux targets KSP (`ksp-*`) ; +- `ConsoleSettings` porte explicitement `enabled`, `output`, `ansi`, `format` et `filter` ; +- `FileSettings` porte `output_id`, `enabled`, destination actuelle `directory/file_name_prefix`, rotation, format, ANSI et filtre ; +- les `output_id` fichier sont uniques, ASCII minuscules segmentables par `.`, et distincts des `file_id` Config ; +- les fichiers persistants refusent `ansi = true` ; +- `LoggingSettings` représente désormais `files: Vec` au lieu d'un seul fichier optionnel, tout en conservant `default_filter`, `TargetFilter`, `SpanEvents` et la console ; +- `validate()` valide le modèle multi-output indépendamment du backend runtime ; +- les helpers `ConsoleSettings::stdout/stderr()` conservent un profil compatible avec le runtime historique ; +- jusqu'à `pre.005`, `initialize/reinitialize` refusent explicitement les nouvelles capacités qu'ils ne savent pas encore appliquer (plusieurs fichiers actifs, formats enrichis, ANSI console ou routing par output) plutôt que de les ignorer silencieusement ; +- aucune dépendance Config n'entre dans Logging et aucune dépendance externe supplémentaire n'est ajoutée. + +La validation utilisateur de `pre.004` reste requise avant l'ouverture de `pre.005`. + ### `0.1.3-pre.005` — `ksp-logging-lib` : runtime multi-sink + routing Objectif unique : implémenter derrière les contrats de `pre.004` le comportement runtime manquant.