Lot 4 : la liste des presets passe à Rust (lecture seule)

Portage fidèle de ListPresets, configFingerprint et activePresetID
(backend_presets.go, web_sysprompt.go) dans loki-rs/src/presets.rs, pour
que /api/status puis GET /api/presets puissent se servir côté façade.

Le Go lisait lui-même LOKI_HOME, la configuration active et l'ordre choisi
par glisser-déposer ; ici tout arrive en paramètres : home, la config
(Store::config) et l'ordre (seau state, clé preset_order, décodé par
parse_preset_order comme le faisait getJSON). Le reste suit le Go à la
lettre : création du dossier presets, fichiers illisibles sautés, tri par
nom puis ordre stocké, clés « appareil » exclues de l'empreinte.

L'empreinte reste un SHA-1 identique octet pour octet à celle du Go, car
elle est aussi stockée ailleurs (réglage auto, résultats de tune). Faute
de crate SHA-1 disponible, une implémentation directe, vérifiée sur les
vecteurs officiels et sur une empreinte calculée côté Go.

presetCtx (pastille de contexte de /api/presets) vient avec, ainsi que
les tests Go correspondants : empreinte insensible à la mise en forme,
ordre des presets, tailles de contexte, preset actif après bascule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-14 17:47:40 +02:00
1 parent c9c9ac1ddd
commit a4b0885a22
1 file changed
+586 -1
+586 -1
View File
@@ -1 +1,586 @@
//! Lot 4 : module en cours de portage.
//! Lot 4 : liste des presets, en LECTURE seule (portage de
//! `internal/loki/backend_presets.go`).
//!
//! Sert `/api/status` (`activePresetID()` de `web_sysprompt.go`) et, plus
//! tard, `GET /api/presets`. L'écriture (création, bascule, suppression…)
//! reste au Go.
//!
//! Différences de forme avec le Go, toutes dues au fait que le Go lit lui-même
//! son environnement et sa base :
//! - `LOKI_HOME` est passé en paramètre (`home`) au lieu d'être lu ici ;
//! - la configuration active (`ReadConfig()`, seau `config`) est passée en
//! paramètre (`cfg`) : l'appelant la lit par `Store::config()` ;
//! - l'ordre choisi par glisser-déposer (seau `state`, clé `preset_order`,
//! un tableau JSON d'ids) est passé en paramètre (`order`) : l'appelant lit
//! la valeur brute par `Store::get(BK_STATE, PRESET_ORDER_KEY)` et la
//! décode par `parse_preset_order`, qui reproduit `getJSON`.
use serde::{Serialize, Serializer};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Clé de l'ordre d'affichage des presets dans le seau `state`
/// (`crate::store::BK_STATE`) — `presetOrderKey` du Go.
pub const PRESET_ORDER_KEY: &str = "preset_order";
/// Réglages « appareil » (`preservedKeys` du Go) : préférences de machine,
/// pas paramètres de modèle. Réappliqués par la bascule de preset, donc
/// ignorés par l'empreinte — sans quoi aucun preset ne serait jamais actif.
pub const PRESERVED_KEYS: &[&str] = &[
"MEM_MODE",
"CRAWL4AI_URL",
"WEB_ENGINE",
"CUDA_VISIBLE_DEVICES",
"HOST",
"CUDA_LAUNCH_QUEUES",
"SLOT_PERSIST",
];
/// Un preset. Son IDENTITÉ est le nom de fichier (sans `.env`), toujours
/// unique ; son nom d'AFFICHAGE vient d'une ligne `# NAME=` facultative.
///
/// Sérialisation identique au `Preset` du Go, qui n'a pas d'étiquettes
/// JSON : noms de champs Go (`ID`, `Name`, `Path`, `Active`), aucun omis.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Preset {
/// Nom de fichier sans `.env` — identité stable et unique.
#[serde(rename = "ID")]
pub id: String,
/// Nom d'affichage (ligne `# NAME=`, à défaut l'id).
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Path", serialize_with = "path_lossy")]
pub path: PathBuf,
/// Le preset correspond-il à la configuration active ?
#[serde(rename = "Active")]
pub active: bool,
}
fn path_lossy<S: Serializer>(p: &Path, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&p.to_string_lossy())
}
/// `presetsDir()` du Go : `$LOKI_HOME/presets`.
pub fn presets_dir(home: &Path) -> PathBuf {
home.join("presets")
}
/// `unquoteValue` du Go : retire les guillemets ENTOURANTS d'une valeur, et
/// seulement eux (une paire ouvrante/fermante du même caractère).
pub fn unquote_value(v: &str) -> &str {
let b = v.as_bytes();
if b.len() >= 2 {
let q = b[0];
if (q == b'"' || q == b'\'') && b[b.len() - 1] == q {
return &v[1..v.len() - 1];
}
}
v
}
/// `parseEnv` du Go : lit le format clé=valeur des presets. Lignes vides et
/// commentaires ignorés, préfixe `export ` toléré, guillemets entourants
/// retirés ; une clé répétée garde sa dernière valeur.
pub fn parse_env(text: &str) -> HashMap<String, String> {
let mut m = HashMap::new();
for line in text.split('\n') {
let s = line.trim();
if s.is_empty() || s.starts_with('#') {
continue;
}
let s = s.strip_prefix("export ").unwrap_or(s);
let Some((k, v)) = s.split_once('=') else {
continue;
};
m.insert(k.trim().to_string(), unquote_value(v.trim()).to_string());
}
m
}
/// `presetDisplayName` du Go : la valeur de la ligne `# NAME=` (le `#` est
/// facultatif, la clé insensible à la casse), sinon `fallback` (l'id).
pub fn preset_display_name(content: &str, fallback: &str) -> String {
for line in content.split('\n') {
let t = line.trim();
let s = t.strip_prefix('#').unwrap_or(t).trim();
if let Some((k, v)) = s.split_once('=') {
// EqualFold du Go : « NAME » n'a pas d'équivalent Unicode hors ASCII.
if k.trim().eq_ignore_ascii_case("NAME") {
let v = unquote_value(v.trim());
if !v.is_empty() {
return v.to_string();
}
}
}
}
fallback.to_string()
}
/// `isPreservedKey` du Go.
pub fn is_preserved_key(key: &str) -> bool {
PRESERVED_KEYS.contains(&key)
}
/// `configFingerprint` du Go : l'ENSEMBLE TRIÉ des affectations `K=V`
/// effectives (clés « appareil » exclues), joint par `\n` puis haché en
/// SHA-1 hexadécimal. Même valeur, octet pour octet, que le Go : elle est
/// aussi stockée ailleurs (réglage auto, résultats de tune).
pub fn config_fingerprint(m: &HashMap<String, String>) -> String {
let mut pairs: Vec<String> = m
.iter()
.filter(|(k, _)| !is_preserved_key(k))
.map(|(k, v)| format!("{k}={v}"))
.collect();
// sort.Strings : ordre des octets, comme Ord sur String.
pairs.sort();
hex::encode(sha1(pairs.join("\n").as_bytes()))
}
/// `presetFingerprint` du Go : `config_fingerprint` du contenu d'un preset.
pub fn preset_fingerprint(content: &str) -> String {
config_fingerprint(&parse_env(content))
}
/// Décode la valeur brute de `state/preset_order` comme `getJSON` du Go dans
/// un `[]string` : absente, vide ou illisible → aucun ordre ; un élément qui
/// n'est pas une chaîne devient `""` (le Go poursuit le décodage sur une
/// erreur de type et laisse la valeur zéro).
pub fn parse_preset_order(raw: Option<&[u8]>) -> Vec<String> {
let Some(raw) = raw.filter(|r| !r.is_empty()) else {
return Vec::new();
};
match serde_json::from_slice::<serde_json::Value>(raw) {
Ok(serde_json::Value::Array(items)) => items
.into_iter()
.map(|v| match v {
serde_json::Value::String(s) => s,
_ => String::new(),
})
.collect(),
_ => Vec::new(),
}
}
/// `applyPresetOrder` du Go : réordonne `list` (déjà triée par nom) selon
/// l'ordre stocké — d'abord les presets classés, dans l'ordre choisi, puis
/// les non classés (fraîchement créés) dans leur ordre alphabétique. Tri
/// STABLE. Un id répété dans `order` prend sa DERNIÈRE position (map Go).
pub fn apply_preset_order(mut list: Vec<Preset>, order: &[String]) -> Vec<Preset> {
if order.is_empty() {
return list;
}
let pos: HashMap<&str, usize> = order
.iter()
.enumerate()
.map(|(i, id)| (id.as_str(), i))
.collect();
list.sort_by(|a, b| {
match (pos.get(a.id.as_str()), pos.get(b.id.as_str())) {
(Some(pa), Some(pb)) => pa.cmp(pb),
// Un preset classé passe avant un non classé.
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
// Deux non classés : l'ordre alphabétique reste.
(None, None) => std::cmp::Ordering::Equal,
}
});
list
}
/// `ListPresets()` du Go : tous les `presets/*.env`, en marquant ceux qui
/// correspondent à la configuration active `cfg` (clés « appareil »
/// ignorées), triés par nom puis selon l'ordre stocké `order`.
///
/// Comme le Go, crée le dossier des presets s'il manque (erreur ignorée) ;
/// un fichier illisible est sauté ; seul un dossier illisible est une erreur.
pub fn list_presets(
home: &Path,
cfg: &HashMap<String, String>,
order: &[String],
) -> Result<Vec<Preset>, String> {
let dir = presets_dir(home);
let _ = std::fs::create_dir_all(&dir);
let cur = config_fingerprint(cfg);
let rd = std::fs::read_dir(&dir).map_err(|e| format!("open {}: {e}", dir.display()))?;
// os.ReadDir renvoie les entrées triées par nom de fichier ; read_dir non.
let mut entries: Vec<_> = rd.filter_map(Result::ok).collect();
entries.sort_by_key(|e| e.file_name());
let mut out = Vec::new();
for e in entries {
// e.IsDir() du Go ne suit pas les liens symboliques : file_type non plus.
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let fname = e.file_name().to_string_lossy().into_owned();
let Some(id) = fname.strip_suffix(".env") else {
continue;
};
let path = dir.join(e.file_name());
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let content = String::from_utf8_lossy(&bytes);
out.push(Preset {
id: id.to_string(),
name: preset_display_name(&content, id),
active: preset_fingerprint(&content) == cur,
path,
});
}
// sort.Slice par Name (octets). Le tri Rust est stable, celui du Go ne
// l'est pas au-delà de 12 éléments : seul l'ordre entre noms ÉGAUX peut
// différer, et le Go ne le garantit de toute façon pas.
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(apply_preset_order(out, order))
}
/// `activePresetID()` du Go (`web_sysprompt.go`) : l'id du PREMIER preset
/// actif dans l'ordre de la liste, `""` si aucun ou en cas d'erreur (config
/// bricolée à la main, aucun preset créé).
pub fn active_preset_id(home: &Path, cfg: &HashMap<String, String>, order: &[String]) -> String {
match list_presets(home, cfg, order) {
Ok(list) => list
.into_iter()
.find(|p| p.active)
.map(|p| p.id)
.unwrap_or_default(),
Err(_) => String::new(),
}
}
/// `presetCtx` du Go : la taille de contexte d'un preset formatée pour la
/// pastille de la liste (« 32K », « 128K », « 1M »). `CTX` absent = 32768,
/// le défaut de serve ; illisible ou ≤ 0 → `""`.
pub fn preset_ctx(content: &str) -> String {
let env = parse_env(content);
let mut raw = env.get("CTX").map(|s| s.trim()).unwrap_or("");
if raw.is_empty() {
raw = "32768";
}
// strconv.Atoi : entier signé 64 bits, signe facultatif.
let n: i64 = match raw.parse() {
Ok(n) if n > 0 => n,
_ => return String::new(),
};
if n >= 1024 && n % 1024 == 0 && n < 1 << 20 {
return format!("{}K", n / 1024);
}
if n >= 1_000_000 {
// FormatFloat(…, 'f', 1, 64) : arrondi exact de la valeur binaire,
// comme le formatage à précision fixe de Rust.
let s = format!("{:.1}", n as f64 / 1_000_000.0);
return format!("{}M", s.strip_suffix(".0").unwrap_or(&s));
}
if n >= 1000 {
return format!("{}K", (n + 500) / 1000);
}
n.to_string()
}
/// SHA-1 (FIPS 180-4). Le Go hache l'empreinte en SHA-1 et aucune crate SHA-1
/// n'est disponible : implémentation directe, vérifiée par les vecteurs
/// officiels dans les tests. Usage d'identification, pas de sécurité.
fn sha1(data: &[u8]) -> [u8; 20] {
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
let mut msg = data.to_vec();
let bit_len = (data.len() as u64).wrapping_mul(8);
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
// Le bourrage aligne `msg` sur 64 octets : le reste est vide.
let (blocks, _) = msg.as_chunks::<64>();
for block in blocks {
let mut w = [0u32; 80];
let (words, _) = block.as_chunks::<4>();
for (wi, word) in w.iter_mut().zip(words) {
*wi = u32::from_be_bytes(*word);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let [mut a, mut b, mut c, mut d, mut e] = h;
for (i, wi) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | (!b & d), 0x5A827999),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let t = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(*wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = t;
}
for (hv, v) in h.iter_mut().zip([a, b, c, d, e]) {
*hv = hv.wrapping_add(v);
}
}
let mut out = [0u8; 20];
let (chunks, _) = out.as_chunks_mut::<4>();
for (chunk, v) in chunks.iter_mut().zip(h) {
*chunk = v.to_be_bytes();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_preset(home: &Path, id: &str, body: &str) {
let dir = presets_dir(home);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{id}.env")), body).unwrap();
}
/// `writeTestPreset` de `web_sysprompt_test.go` : un preset minimal dont
/// le contenu diffère d'un preset à l'autre.
fn write_test_preset(home: &Path, id: &str, model: &str) -> String {
write_preset(home, id, &format!("# NAME={id}\nMODEL={model}\nCTX=8192\n"));
id.to_string()
}
/// `applyPresetFile` réduit à son effet sur l'empreinte : la config devient
/// le preset, plus les réglages « appareil » de l'ancienne config.
fn applied(home: &Path, id: &str, prev: &HashMap<String, String>) -> HashMap<String, String> {
let body = fs::read_to_string(presets_dir(home).join(format!("{id}.env"))).unwrap();
let mut next = parse_env(&body);
for k in PRESERVED_KEYS {
if let Some(v) = prev.get(*k) {
next.insert(k.to_string(), v.clone());
}
}
next
}
#[test]
fn sha1_vecteurs_officiels() {
assert_eq!(
hex::encode(sha1(b"")),
"da39a3ee5e6b4b0d3255bfef95601890afd80709"
);
assert_eq!(
hex::encode(sha1(b"abc")),
"a9993e364706816aba3e25717850c26c9cd0d89d"
);
assert_eq!(
hex::encode(sha1(
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"
)),
"84983e441c3bd26ebaae4aa1f95129e5e54670f1"
);
let million = vec![b'a'; 1_000_000];
assert_eq!(
hex::encode(sha1(&million)),
"34aa973cd4c4daa4f61eeb2bdbad27316534016f"
);
}
/// Empreinte identique, octet pour octet, à celle du Go (valeur calculée
/// par `sha1sum` sur `CTX=4096\nMODEL=foo.gguf\nNGL=999`).
#[test]
fn empreinte_identique_au_go() {
let fp = preset_fingerprint("# NAME=Mon preset\nMODEL=foo.gguf\nCTX=4096\nNGL=999\n");
assert_eq!(fp, "67b0ec4e1d4edc24c8068c15308426144c4a51fc");
}
/// TestPresetFingerprintIgnoresCosmetics : la détection du preset actif ne
/// dépend QUE de la config effective, pas de la mise en forme ni des clés
/// « appareil ».
#[test]
fn preset_fingerprint_ignores_cosmetics() {
let base = "# NAME=Mon preset\nMODEL=foo.gguf\nCTX=4096\nNGL=999\n";
// Même config effective, présentée autrement.
let variant =
"export NGL=999\nMEM_MODE=off\n\nCTX=4096\n# un commentaire\nMODEL=foo.gguf\n";
assert_eq!(
preset_fingerprint(base),
preset_fingerprint(variant),
"empreintes différentes alors que la config effective est identique"
);
// Un vrai changement de valeur doit, lui, produire une empreinte différente.
let changed = "MODEL=foo.gguf\nCTX=8192\nNGL=999\n";
assert_ne!(
preset_fingerprint(base),
preset_fingerprint(changed),
"empreintes identiques alors que CTX diffère"
);
}
/// TestPresetOrder : les presets classés d'abord, dans l'ordre choisi ;
/// les nouveaux (non classés) ensuite, par nom.
#[test]
fn preset_order() {
let home = tempfile::tempdir().unwrap();
for id in ["alpha", "bravo", "charlie", "delta"] {
write_preset(home.path(), id, &format!("# NAME={id}\nCTX=4096\n"));
}
let cfg = HashMap::new();
let ids = |order: &[String]| -> Vec<String> {
list_presets(home.path(), &cfg, order)
.unwrap()
.into_iter()
.map(|p| p.id)
.collect()
};
let got = ids(&[]);
assert!(
got[0] == "alpha" && got[3] == "delta",
"sans ordre enregistré, tri par nom attendu : {got:?}"
);
// savePresetOrder écrit le JSON que relit loadPresetOrder.
let order = parse_preset_order(Some(br#"["charlie","alpha"]"#));
assert_eq!(ids(&order), ["charlie", "alpha", "bravo", "delta"]);
}
/// TestPresetCtx.
#[test]
fn preset_ctx_cases() {
let cases = [
("", "32K"), // défaut de serve : 32768
("CTX=40000\n", "40K"),
("CTX=\"131072\"\n", "128K"),
("CTX=1000000\n", "1M"),
("CTX=1500000\n", "1.5M"),
("CTX=512\n", "512"),
("CTX=abc\n", ""),
// Arrondis vérifiés contre strconv.FormatFloat du Go.
("CTX=1250000\n", "1.2M"),
("CTX=1350000\n", "1.4M"),
("CTX=1050000\n", "1.1M"),
("CTX=2450000\n", "2.5M"),
];
for (input, want) in cases {
assert_eq!(preset_ctx(input), want, "preset_ctx({input:?})");
}
}
/// TestSysPromptSuitLePresetActif (partie activePresetID) : après
/// application d'un preset, c'est lui qui est actif ; puis l'autre.
#[test]
fn active_preset_suit_le_preset_applique() {
let home = tempfile::tempdir().unwrap();
let a = write_test_preset(home.path(), "modele-a", "/models/a.gguf");
let b = write_test_preset(home.path(), "modele-b", "/models/b.gguf");
let cfg = applied(home.path(), &a, &HashMap::new());
assert_eq!(active_preset_id(home.path(), &cfg, &[]), a);
let cfg = applied(home.path(), &b, &cfg);
assert_eq!(active_preset_id(home.path(), &cfg, &[]), b);
let cfg = applied(home.path(), &a, &cfg);
assert_eq!(active_preset_id(home.path(), &cfg, &[]), a);
}
/// TestSysPromptReplieSurLeGlobal (partie activePresetID) : aucun preset →
/// "" ; un preset apparaît et est appliqué → il devient actif.
#[test]
fn active_preset_vide_sans_preset() {
let home = tempfile::tempdir().unwrap();
let id = active_preset_id(home.path(), &HashMap::new(), &[]);
assert_eq!(id, "", "preset actif {id:?} alors qu'aucun n'existe");
// ListPresets crée le dossier, comme le Go.
assert!(presets_dir(home.path()).is_dir());
let a = write_test_preset(home.path(), "modele-a", "/models/a.gguf");
let cfg = applied(home.path(), &a, &HashMap::new());
assert_eq!(active_preset_id(home.path(), &cfg, &[]), a);
}
/// TestModifierLePresetActifLApplique (partie détection) : une config qui
/// porte en plus un réglage « appareil » (MEM_MODE) désigne toujours son
/// preset ; l'autre preset reste inactif ; le nom vient de `# NAME=`.
#[test]
fn preset_actif_malgre_reglage_machine() {
let home = tempfile::tempdir().unwrap();
write_preset(
home.path(),
"actif",
"# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n",
);
write_preset(
home.path(),
"autre",
"# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n",
);
let cfg = parse_env("MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n");
let list = list_presets(home.path(), &cfg, &[]).unwrap();
let summary: Vec<_> = list
.iter()
.map(|p| (p.id.as_str(), p.name.as_str(), p.active))
.collect();
assert_eq!(
summary,
[("actif", "Actif", true), ("autre", "Autre", false)]
);
// Un vrai changement de réglage de modèle : plus aucun preset actif.
let cfg = parse_env("MODEL=foo.gguf\nCTX=100000\nMEM_MODE=always\n");
assert_eq!(active_preset_id(home.path(), &cfg, &[]), "");
}
/// Le dossier contient aussi des sous-dossiers et des fichiers d'autres
/// extensions : seuls les `*.env` (fichiers) sont des presets ; le nom
/// d'affichage retombe sur l'id sans `# NAME=`.
#[test]
fn liste_ignore_le_reste() {
let home = tempfile::tempdir().unwrap();
let dir = presets_dir(home.path());
fs::create_dir_all(dir.join("sous.env")).unwrap();
fs::write(dir.join("notes.txt"), "MODEL=x\n").unwrap();
write_preset(home.path(), "Zeta", "MODEL=z.gguf\n");
write_preset(home.path(), "b", "#name = 'Alpha'\nMODEL=a.gguf\n");
let list = list_presets(home.path(), &HashMap::new(), &[]).unwrap();
let summary: Vec<_> = list
.iter()
.map(|p| (p.id.as_str(), p.name.as_str()))
.collect();
assert_eq!(summary, [("b", "Alpha"), ("Zeta", "Zeta")]);
assert_eq!(list[0].path, dir.join("b.env"));
}
/// Sérialisation : mêmes noms de champs que le `Preset` Go (sans étiquettes).
#[test]
fn serialisation_comme_le_go() {
let p = Preset {
id: "x".into(),
name: "X".into(),
path: PathBuf::from("/h/presets/x.env"),
active: false,
};
assert_eq!(
serde_json::to_string(&p).unwrap(),
r#"{"ID":"x","Name":"X","Path":"/h/presets/x.env","Active":false}"#
);
}
/// Décodage de `state/preset_order` comme `getJSON` dans un `[]string`
/// (comportements vérifiés contre encoding/json).
#[test]
fn decodage_de_l_ordre() {
assert!(parse_preset_order(None).is_empty());
assert!(parse_preset_order(Some(b"")).is_empty());
assert!(parse_preset_order(Some(b"null")).is_empty());
assert!(parse_preset_order(Some(b"pas du json")).is_empty());
assert!(parse_preset_order(Some(br#"{"x":1}"#)).is_empty());
assert_eq!(parse_preset_order(Some(br#"["a",1,"b"]"#)), ["a", "", "b"]);
}
#[test]
fn parse_env_et_guillemets() {
let m = parse_env(
" export A = \"x y\" \n# B=1\nC='z'\nD=\"un\nE=--f \"/t.jinja\"\nF\nA=2\r\n",
);
assert_eq!(m.get("A").map(String::as_str), Some("2"));
assert_eq!(m.get("C").map(String::as_str), Some("z"));
assert_eq!(m.get("D").map(String::as_str), Some("\"un"));
assert_eq!(m.get("E").map(String::as_str), Some("--f \"/t.jinja\""));
assert!(!m.contains_key("B") && !m.contains_key("F"));
}
}