Files
pmomusic/Blackboard/Architecture/pmoconfig_ext.md
Eric Coissac c250801a9f Documentation complète des patterns d'extension pmoconfig, pmoserver_ext et implémentation MusicSource
Ajout de la documentation complète pour les patterns d'extension pmoconfig, pmoserver_ext et l'implémentation d'une nouvelle MusicSource, incluant les guides détaillés, exemples de code et checklists d'implémentation.
2026-01-15 08:18:15 +01:00

30 KiB

Pattern d'extension de pmoconfig::Config

Vue d'ensemble

Ce document décrit le pattern architectural utilisé dans PMOMusic pour étendre la configuration centralisée (pmoconfig::Config) avec des fonctionnalités spécifiques à chaque crate.

Objectif

Permettre à chaque crate du projet d'ajouter ses propres méthodes de configuration sans modifier directement pmoconfig, tout en maintenant une interface cohérente et type-safe.

Principe

Chaque crate qui nécessite un accès à la configuration implémente un trait d'extension pour pmoconfig::Config. Ce trait définit des méthodes helpers spécifiques au domaine du crate (cache, authentification, UPnP, etc.).

Architecture du pattern

Structure de base

pmoconfig/              # Crate de configuration centralisée
  ├── Config            # Struct principale avec get_value/set_value génériques
  └── encryption        # Module de chiffrement des mots de passe

pmocrate/               # Crate spécialisé (audio, qobuz, upnp, etc.)
  └── config_ext.rs     # Trait d'extension pour Config
      ├── DEFAULT_*     # Constantes pour valeurs par défaut
      ├── XxxConfigExt  # Trait d'extension
      └── impl          # Implémentation du trait pour Config

Flux de données

Application
    ↓
Trait d'extension spécialisé (QobuzConfigExt, CacheConfigExt, etc.)
    ↓
pmoconfig::Config (get_value/set_value génériques)
    ↓
Fichier config.yaml

Implémentation d'un trait d'extension

1. Structure du fichier config_ext.rs

//! Extension pour intégrer [fonctionnalité] dans pmoconfig
//!
//! Ce module fournit le trait `XxxConfigExt` qui permet d'ajouter facilement
//! des méthodes de gestion de [fonctionnalité] à pmoconfig::Config.

use anyhow::Result;
use pmoconfig::Config;
use serde_yaml::Value;

// Constantes pour valeurs par défaut
const DEFAULT_XXX_DIR: &str = "cache_xxx";
const DEFAULT_XXX_SIZE: usize = 1000;

/// Trait d'extension pour gérer [fonctionnalité] dans pmoconfig
///
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
/// à la gestion de [fonctionnalité].
///
/// # Exemple
///
/// ```rust,ignore
/// use pmoconfig::get_config;
/// use pmoxxx::XxxConfigExt;
///
/// let config = get_config();
/// let value = config.get_xxx_value()?;
/// ```
pub trait XxxConfigExt {
    /// Documentation de la méthode getter
    fn get_xxx_value(&self) -> Result<Type>;
    
    /// Documentation de la méthode setter
    fn set_xxx_value(&self, value: Type) -> Result<()>;
}

impl XxxConfigExt for Config {
    fn get_xxx_value(&self) -> Result<Type> {
        // Implémentation
    }
    
    fn set_xxx_value(&self, value: Type) -> Result<()> {
        // Implémentation
    }
}

2. Patterns de chemins YAML

Les chemins dans la configuration suivent une hiérarchie logique :

Configuration hôte/système

// Chemins sous "host"
&["host", "cache_type", "directory"]  // Répertoires de cache
&["host", "cache_type", "size"]       // Tailles de cache
&["host", "upnp", "manufacturer"]     // Configuration UPnP

Configuration comptes/services

// Chemins sous "accounts"
&["accounts", "service", "username"]
&["accounts", "service", "password"]
&["accounts", "service", "auth_token"]

Configuration sources

// Chemins sous "sources"
&["sources", "source_name", "enabled"]
&["sources", "source_name", "default_channel"]

3. Patterns de getters

Getter simple avec valeur par défaut

fn get_xxx_value(&self) -> Result<Type> {
    match self.get_value(&["path", "to", "value"]) {
        Ok(Value::Type(v)) => Ok(v),
        _ => Ok(DEFAULT_VALUE),
    }
}

Getter avec auto-persistence

Pour les valeurs qui doivent être visibles dans le fichier YAML, le getter persiste automatiquement la valeur par défaut :

fn get_xxx_enabled(&self) -> Result<bool> {
    match self.get_value(&["path", "to", "enabled"]) {
        Ok(Value::Bool(b)) => Ok(b),
        _ => {
            // Auto-persist la valeur par défaut
            self.set_xxx_enabled(true)?;
            Ok(true)
        }
    }
}

Avantage : L'utilisateur voit la configuration effective dans le YAML et peut la modifier facilement.

Getter optionnel

Pour les valeurs vraiment optionnelles (pas de défaut significatif) :

fn get_xxx_optional(&self) -> Result<Option<String>> {
    match self.get_value(&["path", "to", "optional"]) {
        Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
        Ok(Value::String(_)) => Ok(None), // String vide
        Ok(_) => Ok(None),                // Mauvais type
        Err(_) => Ok(None),               // Non configuré
    }
}

Getter avec traitement spécial

Déchiffrement de mots de passe
fn get_xxx_password(&self) -> Result<String> {
    match self.get_value(&["accounts", "xxx", "password"])? {
        Value::String(s) => {
            // Déchiffrement automatique si chiffré
            pmoconfig::encryption::get_password(&s)
                .map_err(|e| anyhow!("Failed to decrypt password: {}", e))
        }
        _ => Err(anyhow!("Password not configured")),
    }
}
Parsing avec fallback
fn get_xxx_enum_value(&self) -> Result<EnumType> {
    match self.get_value(&["path", "to", "value"]) {
        Ok(Value::String(s)) => {
            match s.parse::<EnumType>() {
                Ok(kind) => Ok(kind),
                Err(_) => {
                    // Valeur invalide, utiliser et persister le défaut
                    self.set_xxx_enum_value(DEFAULT_ENUM)?;
                    Ok(DEFAULT_ENUM)
                }
            }
        }
        Ok(Value::Number(n)) => {
            // Accepter aussi les valeurs numériques
            if let Some(id) = n.as_u64() {
                EnumType::from_id(id as u8)
            } else {
                self.set_xxx_enum_value(DEFAULT_ENUM)?;
                Ok(DEFAULT_ENUM)
            }
        }
        _ => {
            // Non configuré, persister le défaut
            self.set_xxx_enum_value(DEFAULT_ENUM)?;
            Ok(DEFAULT_ENUM)
        }
    }
}

4. Patterns de setters

Setter simple

fn set_xxx_value(&self, value: Type) -> Result<()> {
    self.set_value(
        &["path", "to", "value"],
        Value::Type(value.into())
    )
}

Setter avec transformation

fn set_xxx_enum(&self, variant: EnumVariant) -> Result<()> {
    // Stocker sous forme conviviale (string) plutôt que numérique
    let name = variant.as_str();
    self.set_value(
        &["path", "to", "enum"],
        Value::String(name.to_string())
    )
}

Setter multiple (transaction)

fn set_xxx_auth_info(
    &self,
    token: &str,
    user_id: &str,
    expires_at: u64,
) -> Result<()> {
    // Grouper les modifications liées
    self.set_value(
        &["accounts", "xxx", "auth_token"],
        Value::String(token.to_string())
    )?;
    self.set_value(
        &["accounts", "xxx", "user_id"],
        Value::String(user_id.to_string())
    )?;
    self.set_value(
        &["accounts", "xxx", "token_expires_at"],
        Value::Number(serde_yaml::Number::from(expires_at))
    )?;
    Ok(())
}

Setter de nettoyage

fn clear_xxx_info(&self) -> Result<()> {
    // Ne pas propager les erreurs (valeurs peuvent ne pas exister)
    let _ = self.set_value(&["path", "to", "field1"], Value::String(String::new()));
    let _ = self.set_value(&["path", "to", "field2"], Value::Number(Number::from(0)));
    Ok(())
}

5. Helpers de haut niveau

Helper de validation

fn is_xxx_valid(&self) -> bool {
    // Vérifier plusieurs conditions sans Result
    let has_token = self
        .get_xxx_token()
        .ok()
        .flatten()
        .map(|t| !t.is_empty())
        .unwrap_or(false);
    
    let has_user = self
        .get_xxx_user()
        .ok()
        .flatten()
        .map(|u| !u.is_empty())
        .unwrap_or(false);
    
    has_token && has_user
}

Factory method

Pour les crates qui fournissent des objets complexes configurables :

fn create_xxx_cache(&self) -> Result<Arc<Cache>> {
    let dir = self.get_xxx_dir()?;
    let size = self.get_xxx_size()?;
    Ok(Arc::new(crate::new_cache(&dir, size)?))
}

fn create_xxx_client(&self) -> Result<XxxClient> {
    let (username, password) = self.get_xxx_credentials()?;
    XxxClient::builder()
        .credentials(username, password)
        .cache_dir(self.get_xxx_cache_dir()?)
        .build()
}

Getter combiné

fn get_xxx_credentials(&self) -> Result<(String, String)> {
    let username = self.get_xxx_username()?;
    let password = self.get_xxx_password()?;
    Ok((username, password))
}

6. Utilisation des méthodes pmoconfig

Répertoires managés

Pour les répertoires qui doivent être créés automatiquement :

fn get_xxx_cache_dir(&self) -> Result<String> {
    // get_managed_dir crée le répertoire s'il n'existe pas
    self.get_managed_dir(&["host", "xxx_cache", "directory"], "cache_xxx")
}

fn set_xxx_cache_dir(&self, directory: String) -> Result<()> {
    self.set_managed_dir(&["host", "xxx_cache", "directory"], directory)
}

Méthodes génériques de Config utilisables

// Lecture de valeur générique
pub fn get_value(&self, path: &[&str]) -> Result<Value>

// Écriture de valeur générique
pub fn set_value(&self, path: &[&str], value: Value) -> Result<()>

// Répertoires managés (création auto)
pub fn get_managed_dir(&self, path: &[&str], default: &str) -> Result<String>
pub fn set_managed_dir(&self, path: &[&str], directory: String) -> Result<()>

// Déchiffrement de mots de passe
pub mod encryption {
    pub fn encrypt_password(password: &str) -> Result<String>
    pub fn decrypt_password(encrypted: &str) -> Result<String>
    pub fn get_password(value: &str) -> Result<String>  // Auto-détection
    pub fn is_encrypted(value: &str) -> bool
}

Patterns spécialisés

Pattern cache (pmocache)

Le crate pmocache fournit un trait générique CacheConfigExt que les autres crates de cache peuvent utiliser :

use pmocache::CacheConfigExt;

impl AudioCacheConfigExt for Config {
    fn get_audiocache_dir(&self) -> Result<String> {
        self.get_cache_dir("audio_cache", DEFAULT_AUDIO_CACHE_DIR)
    }
    
    fn create_audio_cache(&self) -> Result<Arc<Cache>> {
        let dir = self.get_audiocache_dir()?;
        let size = self.get_audiocache_size()?;
        Ok(Arc::new(crate::cache::new_cache(&dir, size)?))
    }
}

Avantage : Cohérence entre tous les caches (audio, covers, qobuz, etc.)

Pattern authentification (pmoqobuz)

Pour les services nécessitant une authentification :

pub trait QobuzConfigExt {
    // Credentials de base
    fn get_qobuz_username(&self) -> Result<String>;
    fn get_qobuz_password(&self) -> Result<String>;  // Auto-decrypt
    fn get_qobuz_credentials(&self) -> Result<(String, String)>;
    
    // Tokens d'authentification
    fn get_qobuz_auth_token(&self) -> Result<Option<String>>;
    fn get_qobuz_user_id(&self) -> Result<Option<String>>;
    fn get_qobuz_token_expires_at(&self) -> Result<Option<u64>>;
    
    // Gestion d'authentification groupée
    fn set_qobuz_auth_info(
        &self, 
        token: &str, 
        user_id: &str, 
        expires_at: u64
    ) -> Result<()>;
    fn clear_qobuz_auth_info(&self) -> Result<()>;
    
    // Validation
    fn is_qobuz_auth_valid(&self) -> bool;
}

Pattern rate limiting (pmoqobuz)

Pour les services avec rate limiting :

pub trait QobuzConfigExt {
    fn get_qobuz_rate_limit_max_concurrent(&self) -> Result<Option<usize>>;
    fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>;
    
    fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result<Option<u64>>;
    fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()>;
    
    fn is_qobuz_rate_limiting_enabled(&self) -> bool;
    fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>;
}

Pattern configuration minimale (pmoparadise)

Pour les sources qui nécessitent peu de configuration :

pub trait RadioParadiseConfigExt {
    // Juste enable/disable
    fn get_paradise_enabled(&self) -> Result<bool>;
    fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
    
    // Configuration minimale avec valeurs intelligentes par défaut
    fn get_paradise_default_channel(&self) -> Result<u8>;
    fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
}

Philosophie : Ne configurer que ce qui doit vraiment l'être. Éviter la sur-configuration.

Pattern UPnP (pmoupnp)

Pour la configuration des devices UPnP :

pub trait UpnpConfigExt {
    fn get_upnp_manufacturer(&self) -> Result<String>;
    fn set_upnp_manufacturer(&self, manufacturer: String) -> Result<()>;
    
    fn get_upnp_udn_prefix(&self) -> Result<String>;
    fn set_upnp_udn_prefix(&self, prefix: String) -> Result<()>;
    
    fn get_upnp_model_name_prefix(&self) -> Result<String>;
    fn set_upnp_model_name_prefix(&self, prefix: String) -> Result<()>;
    
    fn get_upnp_friendly_name_prefix(&self) -> Result<String>;
    fn set_upnp_friendly_name_prefix(&self, prefix: String) -> Result<()>;
}

Usage : Différencier plusieurs instances du serveur (dev, prod, test).

Bonnes pratiques

1. Nommage des méthodes

// ✅ BON : Préfixer avec le nom du service/composant
fn get_qobuz_username(&self) -> Result<String>
fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result<String>
fn get_paradise_enabled(&self) -> Result<bool>

// ❌ MAUVAIS : Nom trop générique
fn get_username(&self) -> Result<String>
fn get_directory(&self) -> Result<String>
fn is_enabled(&self) -> bool

2. Gestion des erreurs

// ✅ BON : Retourner Result pour les valeurs obligatoires
fn get_xxx_username(&self) -> Result<String> {
    match self.get_value(&["accounts", "xxx", "username"])? {
        Value::String(s) => Ok(s),
        _ => Err(anyhow!("XXX username not configured")),
    }
}

// ✅ BON : Retourner Option pour les valeurs optionnelles
fn get_xxx_token(&self) -> Result<Option<String>>

// ✅ BON : Retourner bool pour les checks (sans erreur)
fn is_xxx_valid(&self) -> bool

// ❌ MAUVAIS : Panic ou unwrap
fn get_xxx_value(&self) -> String {
    self.get_value(&["path"]).unwrap().as_str().unwrap()
}

3. Valeurs par défaut

// ✅ BON : Constantes en haut du fichier
const DEFAULT_CACHE_SIZE: usize = 500;
const DEFAULT_CACHE_DIR: &str = "cache_audio";

// ✅ BON : Valeurs par défaut documentées
/// Récupère la taille du cache
///
/// # Returns
///
/// Le nombre maximal d'éléments (default: 500)
fn get_cache_size(&self) -> Result<usize>

// ❌ MAUVAIS : Magic numbers
fn get_cache_size(&self) -> Result<usize> {
    match self.get_value(&["cache", "size"]) {
        Ok(Value::Number(n)) => Ok(n.as_u64().unwrap() as usize),
        _ => Ok(500), // Où vient ce 500 ?
    }
}

4. Documentation

Chaque méthode doit avoir :

/// Description courte de ce que fait la méthode
///
/// # Arguments (si applicable)
///
/// * `param` - Description du paramètre
///
/// # Returns
///
/// Description de ce qui est retourné (avec valeur par défaut si applicable)
///
/// # Errors (si Result)
///
/// Description des cas d'erreur
///
/// # Exemple
///
/// ```rust,ignore
/// use pmoconfig::get_config;
/// use pmoxxx::XxxConfigExt;
///
/// let config = get_config();
/// let value = config.get_xxx_value()?;
/// ```
fn get_xxx_value(&self) -> Result<Type>;

5. Organisation du code

Structure du fichier config_ext.rs

//! Documentation du module

// Imports
use anyhow::Result;
use pmoconfig::Config;
use serde_yaml::Value;

// Constantes
const DEFAULT_XXX: Type = value;

// Trait
pub trait XxxConfigExt {
    // Méthodes groupées logiquement
}

// Implémentation
impl XxxConfigExt for Config {
    // Méthodes dans le même ordre que le trait
}

// Tests (optionnel)
#[cfg(test)]
mod tests {
    use super::*;
}

Ordre des méthodes dans le trait

  1. Getters/setters simples
  2. Getters/setters combinés
  3. Helpers de validation
  4. Factory methods
  5. Méthodes de nettoyage
pub trait QobuzConfigExt {
    // 1. Getters/setters simples
    fn get_qobuz_username(&self) -> Result<String>;
    fn set_qobuz_username(&self, username: &str) -> Result<()>;
    fn get_qobuz_password(&self) -> Result<String>;
    fn set_qobuz_password(&self, password: &str) -> Result<()>;
    
    // 2. Getters/setters combinés
    fn get_qobuz_credentials(&self) -> Result<(String, String)>;
    fn set_qobuz_auth_info(&self, ...) -> Result<()>;
    
    // 3. Helpers de validation
    fn is_qobuz_auth_valid(&self) -> bool;
    
    // 4. Factory methods
    fn create_qobuz_client(&self) -> Result<QobuzClient>;
    
    // 5. Méthodes de nettoyage
    fn clear_qobuz_auth_info(&self) -> Result<()>;
}

6. Types de retour

// ✅ BON : Result<T> pour les opérations qui peuvent échouer
fn get_xxx_username(&self) -> Result<String>

// ✅ BON : Result<Option<T>> pour les valeurs optionnelles
fn get_xxx_token(&self) -> Result<Option<String>>

// ✅ BON : bool pour les checks simples
fn is_xxx_enabled(&self) -> bool

// ✅ BON : Result<(T1, T2)> pour retourner plusieurs valeurs liées
fn get_xxx_credentials(&self) -> Result<(String, String)>

// ❌ MAUVAIS : Option<Result<T>> (ordre inversé)
fn get_xxx_value(&self) -> Option<Result<String>>

7. Conversion de types

// ✅ BON : Gérer plusieurs types d'entrée
fn get_xxx_value(&self) -> Result<u64> {
    match self.get_value(&["path", "to", "value"]) {
        Ok(Value::Number(n)) if n.is_u64() => Ok(n.as_u64().unwrap()),
        Ok(Value::Number(n)) if n.is_i64() => Ok(n.as_i64().unwrap() as u64),
        Ok(Value::String(s)) => s.parse::<u64>()
            .map_err(|e| anyhow!("Invalid number: {}", e)),
        _ => Err(anyhow!("Value not configured")),
    }
}

// ✅ BON : Convertir en format convivial pour l'utilisateur
fn set_xxx_channel(&self, channel: u8) -> Result<()> {
    // Stocker "main" au lieu de "0" dans le YAML
    let name = match channel {
        0 => "main",
        1 => "mellow",
        2 => "rock",
        _ => return Err(anyhow!("Invalid channel")),
    };
    self.set_value(&["path"], Value::String(name.to_string()))
}

Exemples d'implémentation complète

Exemple 1 : Cache simple (pmocovers)

//! Extension pour intégrer le cache de couvertures dans pmoconfig

use anyhow::Result;
use pmocache::CacheConfigExt;
use pmoconfig::Config;
use std::sync::Arc;

const DEFAULT_COVER_CACHE_DIR: &str = "cache_covers";
const DEFAULT_COVER_CACHE_SIZE: usize = 2000;

pub trait CoverCacheConfigExt {
    fn get_covers_dir(&self) -> Result<String>;
    fn set_covers_dir(&self, directory: String) -> Result<()>;
    fn get_covers_size(&self) -> Result<usize>;
    fn set_covers_size(&self, size: usize) -> Result<()>;
    fn create_cover_cache(&self) -> Result<Arc<crate::Cache>>;
}

impl CoverCacheConfigExt for Config {
    fn get_covers_dir(&self) -> Result<String> {
        self.get_cache_dir("cover_cache", DEFAULT_COVER_CACHE_DIR)
    }

    fn set_covers_dir(&self, directory: String) -> Result<()> {
        self.set_cache_dir("cover_cache", directory)
    }

    fn get_covers_size(&self) -> Result<usize> {
        self.get_cache_size("cover_cache", DEFAULT_COVER_CACHE_SIZE)
    }

    fn set_covers_size(&self, size: usize) -> Result<()> {
        self.set_cache_size("cover_cache", size)
    }

    fn create_cover_cache(&self) -> Result<Arc<crate::Cache>> {
        let dir = self.get_covers_dir()?;
        let size = self.get_covers_size()?;
        Ok(Arc::new(crate::cache::new_cache(&dir, size)?))
    }
}

Exemple 2 : Service avec authentification (pmoqobuz - simplifié)

//! Extension pour intégrer la configuration Qobuz dans pmoconfig

use anyhow::{anyhow, Result};
use pmoconfig::Config;
use serde_yaml::Value;

pub trait QobuzConfigExt {
    // Credentials
    fn get_qobuz_username(&self) -> Result<String>;
    fn set_qobuz_username(&self, username: &str) -> Result<()>;
    fn get_qobuz_password(&self) -> Result<String>;
    fn set_qobuz_password(&self, password: &str) -> Result<()>;
    fn get_qobuz_credentials(&self) -> Result<(String, String)>;
    
    // Authentification
    fn get_qobuz_auth_token(&self) -> Result<Option<String>>;
    fn get_qobuz_user_id(&self) -> Result<Option<String>>;
    fn set_qobuz_auth_info(&self, token: &str, user_id: &str) -> Result<()>;
    fn clear_qobuz_auth_info(&self) -> Result<()>;
    fn is_qobuz_auth_valid(&self) -> bool;
}

impl QobuzConfigExt for Config {
    fn get_qobuz_username(&self) -> Result<String> {
        match self.get_value(&["accounts", "qobuz", "username"])? {
            Value::String(s) => Ok(s),
            _ => Err(anyhow!("Qobuz username not configured")),
        }
    }

    fn set_qobuz_username(&self, username: &str) -> Result<()> {
        self.set_value(
            &["accounts", "qobuz", "username"],
            Value::String(username.to_string()),
        )
    }

    fn get_qobuz_password(&self) -> Result<String> {
        match self.get_value(&["accounts", "qobuz", "password"])? {
            Value::String(s) => {
                // Déchiffrement automatique
                pmoconfig::encryption::get_password(&s)
                    .map_err(|e| anyhow!("Failed to decrypt password: {}", e))
            }
            _ => Err(anyhow!("Qobuz password not configured")),
        }
    }

    fn set_qobuz_password(&self, password: &str) -> Result<()> {
        self.set_value(
            &["accounts", "qobuz", "password"],
            Value::String(password.to_string()),
        )
    }

    fn get_qobuz_credentials(&self) -> Result<(String, String)> {
        let username = self.get_qobuz_username()?;
        let password = self.get_qobuz_password()?;
        Ok((username, password))
    }

    fn get_qobuz_auth_token(&self) -> Result<Option<String>> {
        match self.get_value(&["accounts", "qobuz", "auth_token"]) {
            Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
            _ => Ok(None),
        }
    }

    fn get_qobuz_user_id(&self) -> Result<Option<String>> {
        match self.get_value(&["accounts", "qobuz", "user_id"]) {
            Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
            _ => Ok(None),
        }
    }

    fn set_qobuz_auth_info(&self, token: &str, user_id: &str) -> Result<()> {
        self.set_value(
            &["accounts", "qobuz", "auth_token"],
            Value::String(token.to_string()),
        )?;
        self.set_value(
            &["accounts", "qobuz", "user_id"],
            Value::String(user_id.to_string()),
        )?;
        Ok(())
    }

    fn clear_qobuz_auth_info(&self) -> Result<()> {
        let _ = self.set_value(
            &["accounts", "qobuz", "auth_token"],
            Value::String(String::new()),
        );
        let _ = self.set_value(
            &["accounts", "qobuz", "user_id"],
            Value::String(String::new()),
        );
        Ok(())
    }

    fn is_qobuz_auth_valid(&self) -> bool {
        self.get_qobuz_auth_token()
            .ok()
            .flatten()
            .map(|t| !t.is_empty())
            .unwrap_or(false)
            && self
                .get_qobuz_user_id()
                .ok()
                .flatten()
                .map(|u| !u.is_empty())
                .unwrap_or(false)
    }
}

Exemple 3 : Configuration minimale (pmoparadise)

//! Extension pour intégrer Radio Paradise dans pmoconfig

use anyhow::Result;
use pmoconfig::Config;
use serde_yaml::Value;

pub trait RadioParadiseConfigExt {
    fn get_paradise_enabled(&self) -> Result<bool>;
    fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
    fn get_paradise_default_channel(&self) -> Result<u8>;
    fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
}

impl RadioParadiseConfigExt for Config {
    fn get_paradise_enabled(&self) -> Result<bool> {
        match self.get_value(&["sources", "radio_paradise", "enabled"]) {
            Ok(Value::Bool(b)) => Ok(b),
            _ => {
                // Auto-persist le défaut
                self.set_paradise_enabled(true)?;
                Ok(true)
            }
        }
    }

    fn set_paradise_enabled(&self, enabled: bool) -> Result<()> {
        self.set_value(
            &["sources", "radio_paradise", "enabled"],
            Value::Bool(enabled),
        )
    }

    fn get_paradise_default_channel(&self) -> Result<u8> {
        match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
            Ok(Value::String(s)) => {
                // Accepter les noms conviviaux
                match s.as_str() {
                    "main" => Ok(0),
                    "mellow" => Ok(1),
                    "rock" => Ok(2),
                    "eclectic" => Ok(3),
                    _ => {
                        self.set_paradise_default_channel(0)?;
                        Ok(0)
                    }
                }
            }
            Ok(Value::Number(n)) if n.is_u64() => {
                let ch = n.as_u64().unwrap();
                if ch <= 3 {
                    Ok(ch as u8)
                } else {
                    self.set_paradise_default_channel(0)?;
                    Ok(0)
                }
            }
            _ => {
                self.set_value(
                    &["sources", "radio_paradise", "default_channel"],
                    Value::String("main".to_string()),
                )?;
                Ok(0)
            }
        }
    }

    fn set_paradise_default_channel(&self, channel: u8) -> Result<()> {
        let name = match channel {
            0 => "main",
            1 => "mellow",
            2 => "rock",
            3 => "eclectic",
            _ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)),
        };
        self.set_value(
            &["sources", "radio_paradise", "default_channel"],
            Value::String(name.to_string()),
        )
    }
}

Intégration dans un crate

Structure recommandée

pmoxxx/
├── Cargo.toml
├── src/
│   ├── lib.rs         # Exporte le trait d'extension
│   ├── config_ext.rs  # Implémentation du trait
│   └── ...           # Reste du code du crate

Dans Cargo.toml

[dependencies]
pmoconfig = { path = "../pmoconfig" }
anyhow = "1.0"
serde_yaml = "0.9"

# Si c'est un cache, inclure pmocache
pmocache = { path = "../pmocache", optional = false }

Dans lib.rs

// Exporter le trait pour qu'il soit utilisable
pub mod config_ext;
pub use config_ext::XxxConfigExt;

// Le reste du code du crate
// ...

Utilisation dans le code applicatif

use pmoconfig::get_config;
use pmoxxx::XxxConfigExt;

fn main() -> anyhow::Result<()> {
    let config = get_config();
    
    // Utiliser les méthodes du trait d'extension
    let value = config.get_xxx_value()?;
    config.set_xxx_value(new_value)?;
    
    // Factory method
    let client = config.create_xxx_client()?;
    
    Ok(())
}

Checklist pour créer un nouveau trait d'extension

  • Créer le fichier src/config_ext.rs dans le crate
  • Définir les constantes pour les valeurs par défaut
  • Créer le trait XxxConfigExt avec documentation
  • Implémenter les getters avec gestion d'erreur appropriée
  • Implémenter les setters
  • Ajouter les helpers de validation si nécessaire
  • Ajouter les factory methods si applicable
  • Documenter chaque méthode avec exemples
  • Exporter le trait dans lib.rs
  • Ajouter pmoconfig dans Cargo.toml
  • Tester l'intégration

Philosophie du pattern

Avantages

  1. Séparation des préoccupations : Chaque crate gère sa propre configuration
  2. Type safety : Les erreurs de type sont détectées à la compilation
  3. Extensibilité : Facile d'ajouter de nouveaux crates sans modifier pmoconfig
  4. Cohérence : Pattern uniforme dans tout le projet
  5. Documentation : Interface self-documenting avec exemples

Principes directeurs

  1. Minimalisme : Ne configurer que ce qui doit vraiment l'être
  2. Defaults intelligents : Valeurs par défaut sensées et documentées
  3. Auto-persistence : Les valeurs importantes sont persistées automatiquement
  4. User-friendly : Noms conviviaux dans le YAML (strings au lieu de nombres)
  5. Fail-safe : Gestion des erreurs gracieuse avec fallback sur défauts
  6. Zero surprise : Comportement prévisible et cohérent

Sécurité : Chiffrement des mots de passe

Tous les mots de passe dans la configuration doivent pouvoir être chiffrés. Voir pmoconfig/PASSWORD_ENCRYPTION.md pour les détails.

Pattern pour les mots de passe

fn get_xxx_password(&self) -> Result<String> {
    match self.get_value(&["accounts", "xxx", "password"])? {
        Value::String(s) => {
            // Déchiffrement automatique
            pmoconfig::encryption::get_password(&s)
                .map_err(|e| anyhow!("Failed to decrypt password: {}", e))
        }
        _ => Err(anyhow!("Password not configured")),
    }
}

fn set_xxx_password(&self, password: &str) -> Result<()> {
    // Le chiffrement est fait manuellement par l'utilisateur avec l'outil
    self.set_value(
        &["accounts", "xxx", "password"],
        Value::String(password.to_string()),
    )
}

Important : Le setter stocke le mot de passe tel quel. C'est l'utilisateur qui décide de le chiffrer ou non avec l'outil encrypt_password.

Références

Fichiers d'exemple à consulter

  • Cache générique : pmocache/src/config_ext.rs
  • Cache spécialisé : pmocovers/src/config_ext.rs ou pmoaudiocache/src/config_ext.rs
  • Service avec auth : pmoqobuz/src/config_ext.rs
  • Configuration minimale : pmoparadise/src/config_ext.rs
  • Configuration UPnP : pmoupnp/src/config_ext.rs
  • Chiffrement : pmoconfig/PASSWORD_ENCRYPTION.md

Documentation pmoconfig

  • pmoconfig::Config::get_value()
  • pmoconfig::Config::set_value()
  • pmoconfig::Config::get_managed_dir()
  • pmoconfig::encryption module