mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 14:08:11 +02:00
Même empreinte (vecteurs officiels et empreinte du Go toujours vérifiés par les tests), cinquante lignes de cryptographie en moins à maintenir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
538 lines
21 KiB
Rust
538 lines
21 KiB
Rust
//! 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 (crate RustCrypto `sha1`), comme `crypto/sha1` côté Go.
|
|
fn sha1(data: &[u8]) -> [u8; 20] {
|
|
use sha1::{Digest, Sha1};
|
|
Sha1::digest(data).into()
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|