lucida 1.1.0

Generate images and video with Google Gemini, Veo, Runway, Kling, a local ComfyUI, FLUX, Stability AI or OpenAI — a CLI and an MCP server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
//! Settings that survive not having a shell.
//!
//! Lucida read its credentials from the environment alone for its first two
//! versions, which is correct for a CLI and quietly broken for an MCP server.
//! A GUI-launched application on macOS does not inherit a login shell's
//! environment, so a `GEMINI_API_KEY` exported in `~/.zshenv` is invisible to
//! Claude Code launched from the Dock — and therefore to every server it spawns.
//! The failure is confusing rather than obvious: the same binary works perfectly
//! from a terminal.
//!
//! The usual workarounds are both worse than a file. `launchctl setenv` exports
//! the secret to *every* process in the login session and does not survive a
//! reboot; putting the value in the MCP client's own config hardcodes a
//! credential into a JSON file that tends to get shared.
//!
//! So: an optional file of `KEY=value` lines, holding values for exactly the
//! variables Lucida already documents.
//!
//! # Which wins
//!
//! The file, and this reverses the original rule — see [`var`] for why. In
//! short: a file entry is an explicit statement about Lucida, a shell export is
//! ambient and applies to everything, and the specific one should win.
//!
//! # Why not TOML
//!
//! Because the keys *are* environment variable names, and any other format would
//! invent a second vocabulary for the same seven settings — `gemini.api_key` in
//! a file and `GEMINI_API_KEY` in the environment, with a mapping table to keep in
//! sync. It also keeps the parser to a few lines and adds no dependency, which
//! is the same reasoning that kept a JSON-RPC crate out of `mcp.rs`.

use anyhow::Result;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

/// Every setting Lucida will read from a config file, for `lucida config` and
/// for the template it writes.
pub const KNOWN_KEYS: &[(&str, &str)] = &[
    ("GEMINI_API_KEY", "Gemini API key — images and Veo video"),
    ("BFL_API_KEY", "Black Forest Labs API key (hosted FLUX)"),
    ("STABILITY_API_KEY", "Stability AI developer platform key"),
    ("OPENAI_API_KEY", "OpenAI API key"),
    // `RUNWAY_API_KEY`, not Runway's own `RUNWAYML_API_SECRET`. Owner's call,
    // 2026-08-09, and the better one: every other credential here is
    // `<PROVIDER>_API_KEY`, and house consistency across six keys beats matching
    // one vendor's spelling. No retirement entry is owed — Runway support has
    // never appeared in a release, so no configuration anywhere holds the old
    // name except the machine this was written on.
    ("RUNWAY_API_KEY", "Runway API key (Gen-4 video)"),
    ("KLINGAI_API_KEY", "Kling API key (video) — the single-key scheme, not AK/SK"),
    ("LUCIDA_COMFYUI_URL", "Where ComfyUI is listening"),
    ("LUCIDA_COMFYUI_AUTH", "ComfyUI credentials, if it is fenced"),
    ("LUCIDA_COMFYUI_CA", "PEM certificate for a private CA"),
    // Ordered preference, consulted only when a render names neither provider
    // nor model. ⚠ A preference is not a fallback: the list is walked once,
    // before anything is sent, and the first entry whose credential is present
    // wins outright. A provider that then refuses a parameter still refuses —
    // it does not hand the render to the next name. See
    // `provider::resolve_default`, which is where that guarantee lives.
    (
        "LUCIDA_IMAGE_PROVIDERS",
        "Ordered image providers to default to, comma-separated (e.g. bfl,google)",
    ),
    (
        "LUCIDA_VIDEO_PROVIDERS",
        "Ordered video providers to default to, comma-separated (e.g. runway,google)",
    ),
    (
        "LUCIDA_NO_UPDATE_CHECK",
        "Set to silence the daily \"a newer release exists\" notice",
    ),
    (
        "LUCIDA_NO_LEDGER",
        "Set to stop recording renders (the ledger stores your prompts)",
    ),
    // Missing from this list until 2026-08-09, and it was the worst omission to
    // have: `spend::budget` reads it through `var` like everything else, so a
    // budget set in the config file was fully enforced — while `lucida config`
    // filed it under "in the config file but not recognised (ignored — check the
    // spelling)". The one command whose job is to say what is in effect said the
    // spending cap was not.
    //
    // Which way that misleads is what makes it worth a comment. Someone who
    // believes the message concludes they have no cap and behaves accordingly,
    // or deletes a line that was protecting them. `every_setting_read_is_a_known_key`
    // now derives this list's completeness from the source rather than from
    // whoever remembers to update both places.
    (
        "LUCIDA_BUDGET",
        "Dollars of estimated spend allowed per rolling 24 hours",
    ),
];

/// Names Lucida used to read and no longer does, with what replaced each.
///
/// A rename cannot simply delete the old name. Someone who exported
/// `GOOGLE_API_KEY` did nothing wrong, and dropping it silently turns a working
/// setup into "no API key found" — a message that sends them to check a key that
/// is present and correct. So the retired name is still *recognised*, purely to
/// say what happened, and never used as a credential.
///
/// `GOOGLE_API_KEY` was dropped in favour of `GEMINI_API_KEY` because everything
/// Lucida reaches on Google is the Gemini API — images and Veo alike — and after
/// Imagen's 2026-08-17 shutdown there is nothing left that "Google" named
/// more accurately. One name, and it matches the one Google's own documentation
/// uses.
pub const RETIRED_KEYS: &[(&str, &str)] = &[("GOOGLE_API_KEY", "GEMINI_API_KEY")];

/// Whether `name` is a retired spelling, and what to use instead.
pub fn replacement_for(name: &str) -> Option<&'static str> {
    RETIRED_KEYS
        .iter()
        .find(|(old, _)| *old == name)
        .map(|(_, new)| *new)
}

/// Retired names this process can see **whose replacement is still missing**.
///
/// Reported by `lucida config`, so the diagnosis is "you have the old name"
/// rather than "you have no key".
///
/// The condition is the whole point. Once the replacement is set the migration
/// is finished, and the old name is just another variable in the environment
/// that Lucida does not read — no different from the hundred others. Reporting
/// it anyway would be a permanent notice about a non-problem, actionable only by
/// editing a shell profile for tidiness, and a notice that cannot be acted on is
/// one people learn to skip past. That costs the notices that do matter.
///
/// So this fires exactly while it is load-bearing: when someone has the old key
/// and nothing else, and would otherwise be told they have no key at all.
pub fn retired_in_use() -> Vec<(&'static str, &'static str)> {
    RETIRED_KEYS
        .iter()
        .filter(|(old, new)| origin(old).is_some() && var(new).is_none())
        .copied()
        .collect()
}

/// Setting names present in the config file, whether or not Lucida knows them.
///
/// Exists so `lucida config` can surface a key it does *not* recognise. A
/// mistyped name is otherwise perfectly silent: the file looks right, the value
/// is there, and the tool simply never reads it.
pub fn keys_in_file() -> Vec<String> {
    let mut names: Vec<String> = loaded().values.keys().cloned().collect();
    names.sort();
    names
}

struct Loaded {
    path: Option<PathBuf>,
    values: HashMap<String, String>,
}

static LOADED: OnceLock<Loaded> = OnceLock::new();

/// Looks up a setting: the config file first, then the environment.
///
/// **The file wins, and that reverses the rule this shipped with.** Through
/// v0.5.2 the environment won and the file was a fallback, on the reasoning that
/// introducing a config file must not change the behaviour of a setup that
/// already worked. That was a sound migration property, and it cost more than it
/// protected.
///
/// The case it made unreachable: a shell exports `OPENAI_API_KEY` for general
/// use, and you want Lucida to use a *different* key — a fine-grained one scoped
/// to this tool. There was no way to say so. `config --set` wrote the value,
/// reported success, and every render went on using the ambient key. Not merely
/// awkward: impossible, and silently so, which is the failure mode this codebase
/// refuses everywhere else.
///
/// A file entry is an explicit statement about Lucida specifically; a shell
/// export is ambient and applies to everything that reads that name. The
/// specific one should win. The one-off override survives as
/// `LUCIDA_CONFIG=other.env lucida …`, which names a whole file and wins
/// outright — see [`search_paths`].
///
/// An empty environment variable counts as absent. Exporting `GEMINI_API_KEY=`
/// is how a shell profile reports "I meant to set this", and treating it as a
/// real value produces an authentication error instead of a useful one. `parse`
/// drops empty file values for the same reason.
pub fn var(name: &str) -> Option<String> {
    if let Some(value) = loaded().values.get(name) {
        return Some(value.clone());
    }

    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}

/// Where a setting's value is coming from.
///
/// Exists so `lucida config` can report the *loser* as well as the winner. "Set
/// in both" and "set in one" resolve to the same value but not to the same
/// situation, and the whole class of bug here is about which source a process
/// actually reaches.
#[derive(Debug, PartialEq, Eq)]
pub enum Origin {
    /// In the file only.
    File,
    /// In the environment only.
    Environment,
    /// In both. The file is used; the environment value is not.
    FileOverridingEnvironment,
}

/// Which source is supplying `name`, if any.
pub fn origin(name: &str) -> Option<Origin> {
    origin_of(
        loaded().values.contains_key(name),
        std::env::var(name).is_ok_and(|v| !v.trim().is_empty()),
    )
}

/// The precedence table itself, separated from where the two answers come from.
///
/// Pulled out because `loaded()` is a process-wide `OnceLock` — the first test
/// to touch it fixes it for every other — so the resolution rule would otherwise
/// only be checkable from `scripts/smoke.sh`, in a fresh process. It is checked
/// there too, end to end; this pins the table itself.
fn origin_of(in_file: bool, in_env: bool) -> Option<Origin> {
    match (in_file, in_env) {
        (true, true) => Some(Origin::FileOverridingEnvironment),
        (true, false) => Some(Origin::File),
        (false, true) => Some(Origin::Environment),
        (false, false) => None,
    }
}

fn loaded() -> &'static Loaded {
    LOADED.get_or_init(|| {
        for path in search_paths() {
            if path.is_file() {
                let values = match std::fs::read_to_string(&path) {
                    Ok(text) => parse(&text),
                    Err(e) => {
                        // Never fatal: a config file is an optional convenience,
                        // and the environment may well hold everything needed.
                        eprintln!("warning: could not read {}: {e}", path.display());
                        continue;
                    }
                };
                warn_if_readable_by_others(&path);
                return Loaded {
                    path: Some(path),
                    values,
                };
            }
        }

        Loaded {
            path: None,
            values: HashMap::new(),
        }
    })
}

/// The file actually in use, if any.
pub fn source() -> Option<&'static Path> {
    loaded().path.as_deref()
}

/// Where a config file is looked for, in order.
///
/// `LUCIDA_CONFIG` names a file directly and wins outright, which is what makes
/// the whole thing testable and lets a launcher point at a managed location.
pub fn search_paths() -> Vec<PathBuf> {
    if let Some(explicit) = std::env::var("LUCIDA_CONFIG")
        .ok()
        .filter(|p| !p.trim().is_empty())
    {
        return vec![PathBuf::from(explicit)];
    }

    let mut paths = Vec::new();

    if let Some(base) = std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .filter(|p| !p.as_os_str().is_empty())
        .or_else(|| home().map(|home| home.join(".config")))
    {
        paths.push(base.join("lucida").join("config.env"));
    }

    // Checked second rather than first even on macOS: someone with a dotfiles
    // repo expects `~/.config` to work everywhere, and the native location is
    // the more discoverable fallback rather than the more likely one.
    #[cfg(target_os = "macos")]
    if let Some(home) = home() {
        paths.push(
            home.join("Library")
                .join("Application Support")
                .join("lucida")
                .join("config.env"),
        );
    }

    // Same bargain on Windows, where `%APPDATA%` is the native answer. It is a
    // separate variable rather than a subdirectory of the profile, so it has to
    // be read even though `home()` now resolves — an `XDG_CONFIG_HOME` that
    // happens to be set on Windows would otherwise be the only entry.
    #[cfg(target_os = "windows")]
    if let Some(appdata) = std::env::var_os("APPDATA")
        .map(PathBuf::from)
        .filter(|p| !p.as_os_str().is_empty())
    {
        paths.push(appdata.join("lucida").join("config.env"));
    }

    paths
}

/// The preferred path, for messages that tell someone where to put a key.
pub fn preferred_path() -> Option<PathBuf> {
    search_paths().into_iter().next()
}

/// `HOME`, or `USERPROFILE` where there is none — which is stock Windows.
///
/// This read `HOME` alone until 2026-08-06, while `update.rs` and `setup.rs` both
/// read the pair. On PowerShell, cmd, or a GUI-launched client there is no `HOME`
/// and no `XDG_CONFIG_HOME`, so [`search_paths`] came back empty: no file was
/// ever found, and `config --init` and `--set` had nowhere to write. The case
/// that failed is the one this module exists for — a client that inherits no
/// shell and can therefore only read a file.
///
/// It stayed invisible because the Windows CI job runs `scripts/smoke.sh` under
/// bash, and git-bash *sets* `HOME`. So the check that "the config file is read
/// with no environment" passed in an environment no native Windows user has.
fn home() -> Option<PathBuf> {
    home_from(std::env::var_os("HOME"), std::env::var_os("USERPROFILE"))
}

/// Split from [`home`] so the Windows case is testable on a machine that is not
/// Windows — which is where this needed testing, given that the platform's own
/// CI lane could not see the bug.
fn home_from(home: Option<OsString>, user_profile: Option<OsString>) -> Option<PathBuf> {
    [home, user_profile]
        .into_iter()
        .flatten()
        .map(PathBuf::from)
        .find(|p| !p.as_os_str().is_empty())
}

/// Parses `KEY=value` lines.
///
/// Deliberately forgiving in the two ways that matter in practice: a leading
/// `export` is accepted, so a fragment of a shell profile can be copied or
/// symlinked straight in, and surrounding quotes are stripped, because a key
/// pasted from documentation usually arrives wearing them.
fn parse(text: &str) -> HashMap<String, String> {
    let mut values = HashMap::new();

    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        let line = line.strip_prefix("export ").unwrap_or(line).trim_start();

        let Some((key, value)) = line.split_once('=') else {
            continue;
        };

        let key = key.trim();
        if key.is_empty() {
            continue;
        }

        let value = value.trim();
        let value = value
            .strip_prefix('"')
            .and_then(|v| v.strip_suffix('"'))
            .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
            .unwrap_or(value);

        if !value.is_empty() {
            values.insert(key.to_string(), value.to_string());
        }
    }

    values
}

/// Says something once if the file holding an API key is readable by others.
///
/// A warning rather than a refusal: it is the user's machine and their call, and
/// a tool that stops working over a permission bit is a tool people route
/// around.
#[cfg(unix)]
fn warn_if_readable_by_others(path: &Path) {
    use std::os::unix::fs::PermissionsExt;

    if let Ok(metadata) = std::fs::metadata(path) {
        let mode = metadata.permissions().mode();
        if mode & 0o077 != 0 {
            eprintln!(
                "warning: {} is readable by other users (mode {:o}). It may hold an \
                 API key — consider `chmod 600 {}`.",
                path.display(),
                mode & 0o777,
                path.display()
            );
        }
    }
}

#[cfg(not(unix))]
fn warn_if_readable_by_others(_path: &Path) {}

/// Replaces a file's contents without ever leaving it truncated.
///
/// Worth the guarantee because of *whose* files these are. `config.env` may hold
/// the only copy of an API key, and the desktop app's config holds other servers'
/// registrations — neither is a file Lucida created, and both are ones a caller
/// would have to reconstruct by hand.
///
/// The mechanism is [`crate::write_atomically`], shared with image writes since
/// 2026-08-09. It was written here first and lived here alone for three
/// releases, during which `write_image` truncated over the user's original on
/// every `lucida edit` — the house pattern existing is not the same as the house
/// using it.
pub fn write_replacing(path: &Path, body: &str, private: bool) -> Result<()> {
    crate::write_atomically(path, body.as_bytes(), private)
}

/// Restricts a file to its owner.
///
/// Done rather than left to the umask because these files are intended to hold an
/// API key, and the default umask on most systems leaves them readable by the
/// whole group.
#[cfg(unix)]
pub fn restrict_to_owner(path: &Path) -> Result<()> {
    // Imported here rather than at the top of the file: this is the only caller
    // in the module and it does not exist on Windows, so a file-level import
    // becomes an unused-import error there — which is exactly how CI caught it.
    use anyhow::Context;
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
        .with_context(|| format!("restricting permissions on {}", path.display()))
}

#[cfg(not(unix))]
pub fn restrict_to_owner(_path: &Path) -> Result<()> {
    Ok(())
}

/// A starter file, written by `lucida config --init`.
pub fn template() -> String {
    let mut text = String::from(
        "# Lucida settings.\n\
         #\n\
         # A value here takes precedence over the same name in the environment,\n\
         # so this is where a key scoped to Lucida goes when your shell already\n\
         # exports a broader one. It is also the only place a GUI-launched MCP\n\
         # client can find a key at all, since it inherits no shell.\n\
         #\n\
         # `lucida config` reports which source each setting is coming from.\n\
         #\n\
         # Keep this file private: chmod 600\n\n",
    );

    // Every known key, and only known keys — a retired name has no slot here,
    // since offering one would invite writing a value nothing reads.
    for (key, purpose) in KNOWN_KEYS {
        text.push_str(&format!("# {purpose}\n#{key}=\n\n"));
    }

    text
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_the_forms_a_shell_profile_produces() {
        let values = parse(
            "# a comment\n\
             \n\
             GOOGLE_API_KEY=plain\n\
             export LUCIDA_COMFYUI_URL=\"https://host:8188\"\n\
             	LUCIDA_COMFYUI_AUTH = 'bob:hunter2'  \n\
             MALFORMED\n\
             EMPTY=\n",
        );

        assert_eq!(values.get("GOOGLE_API_KEY").unwrap(), "plain");
        // `export` stripped, quotes stripped.
        assert_eq!(values.get("LUCIDA_COMFYUI_URL").unwrap(), "https://host:8188");
        assert_eq!(values.get("LUCIDA_COMFYUI_AUTH").unwrap(), "bob:hunter2");
        // A line with no `=` is skipped rather than fatal.
        assert!(!values.contains_key("MALFORMED"));
        // An empty value is the same as absent, so a later fallback still runs.
        assert!(!values.contains_key("EMPTY"));
    }

    #[test]
    fn a_retired_name_reports_its_replacement() {
        assert_eq!(replacement_for("GOOGLE_API_KEY"), Some("GEMINI_API_KEY"));
        assert_eq!(replacement_for("GEMINI_API_KEY"), None);
        assert_eq!(replacement_for("BFL_API_KEY"), None);

        // A retired name must not also be a live one, or `config --set` would
        // refuse to write a setting Lucida actually reads.
        for (retired, _) in RETIRED_KEYS {
            assert!(
                !KNOWN_KEYS.iter().any(|(known, _)| known == retired),
                "{retired} is both retired and current"
            );
        }
    }

    /// The README's settings table must list every setting, and no others.
    ///
    /// It claims to be exhaustive, which is a claim worth enforcing rather than
    /// making: `README.md` is one of the drift surfaces AGENTS.md names, it is
    /// the first thing anyone reads, and its previous table both omitted a
    /// setting that existed and listed `LUCIDA_CONFIG` among names you can
    /// `--set` — which you cannot, since it names the file the settings live in.
    ///
    /// Checked in both directions. A missing row is a setting nobody knows
    /// about; a surplus row is a setting that does not exist, which costs
    /// somebody an afternoon.
    #[test]
    fn the_readme_lists_every_setting() {
        let readme = std::fs::read_to_string(
            Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
        )
        .expect("README.md must exist");

        // The one table between the marker and the paragraph after it — scoped
        // so a name merely *mentioned* in prose elsewhere does not count as
        // documented, and so the environment-only table below it does not.
        let marker = "<!-- SETTINGS TABLE:";
        let start = readme.find(marker).expect(
            "the settings table has lost its marker comment, so this test is \
             no longer checking anything",
        );
        // Collected row by row rather than by slicing to the next blank line:
        // the marker is separated from its table by one, so slicing that way
        // captured nothing at all and the test passed by checking an empty
        // string. Taking the rows themselves cannot fail that way.
        let table: String = readme[start..]
            .lines()
            .skip_while(|line| !line.starts_with('|'))
            .take_while(|line| line.starts_with('|'))
            .collect::<Vec<_>>()
            .join("\n");

        for (key, _) in KNOWN_KEYS {
            assert!(
                table.contains(&format!("`{key}`")),
                "{key} is a real setting and the README's table does not list it"
            );
        }

        // And nothing in the table that is not a setting. Every row names its
        // key first in backticks. Rows only, so the header — which is itself in
        // backticks, being `lucida config --set …` — is not read as a name.
        let rows = table
            .lines()
            .skip_while(|line| !line.starts_with("|---"))
            .skip(1);

        for line in rows.filter(|l| l.starts_with("| `")) {
            let name = line
                .trim_start_matches("| `")
                .split('`')
                .next()
                .unwrap_or_default();
            assert!(
                KNOWN_KEYS.iter().any(|(known, _)| *known == name),
                "the README's settings table lists `{name}`, which Lucida does not read"
            );
        }
    }

    /// Anything read through [`var`] must appear in [`KNOWN_KEYS`].
    ///
    /// `KNOWN_KEYS` is not documentation — it is what `lucida config` reports,
    /// what `--init` writes into the template, and what `--set` will accept. A
    /// setting missing from it is read normally and reported as **"ignored —
    /// check the spelling"**, which is a lie in the most damaging direction: it
    /// says a setting that is in force is not. `LUCIDA_BUDGET` sat that way from
    /// the day the budget guard shipped, so someone could set a spending cap,
    /// have it enforced, and be told it did nothing.
    ///
    /// Scanned from the sources rather than listed here, so a new setting is
    /// covered the moment it is read — a second hand-written list would only
    /// move the problem. Directory-walked rather than `include_str!`ed for the
    /// same reason: a new module joins without anyone remembering to add it.
    #[test]
    fn every_setting_read_is_a_known_key() {
        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
        let mut checked: Vec<String> = Vec::new();

        for entry in std::fs::read_dir(&src).expect("src/ must be readable") {
            let path = entry.unwrap().path();
            if path.extension().is_none_or(|e| e != "rs") {
                continue;
            }
            let body = std::fs::read_to_string(&path).unwrap();

            // A qualified call with a literal name. Call sites passing a
            // constant instead — `update::OPT_OUT` — are not matched and do not
            // need to be: a constant is already a single definition, which is
            // the property being enforced here.
            let call = concat!("config::", "var(\"");
            for (offset, _) in body.match_indices(call) {
                let rest = &body[offset + call.len()..];
                let name = &rest[..rest.find('"').expect("an unterminated string literal")];
                checked.push(name.to_string());

                assert!(
                    KNOWN_KEYS.iter().any(|(known, _)| *known == name),
                    "{}: `{name}` is read but missing from KNOWN_KEYS, so `lucida config` \
                     reports it as ignored while Lucida acts on it",
                    path.file_name().unwrap().to_string_lossy()
                );
            }
        }

        // The scan has to actually be finding call sites, or this test passes by
        // matching nothing at all — which is how it would behave the day someone
        // renames `var` or qualifies it differently. Two settings read from two
        // different modules, so a change that breaks the scan cannot look like a
        // change that merely moved one caller.
        for expected in ["GEMINI_API_KEY", "LUCIDA_BUDGET"] {
            assert!(
                checked.iter().any(|name| name == expected),
                "the scan found {} call sites and none of them was {expected} — \
                 it has stopped matching how settings are read",
                checked.len()
            );
        }
    }

    #[test]
    fn the_file_outranks_the_environment() {
        // Reversed after v0.5.2. The case that forced it: a shell exporting a broad
        // key made a Lucida-scoped one unreachable, since the ambient value won
        // and `config --set` reported success anyway.
        assert_eq!(
            origin_of(true, true),
            Some(Origin::FileOverridingEnvironment)
        );
        assert_eq!(origin_of(true, false), Some(Origin::File));
        assert_eq!(origin_of(false, true), Some(Origin::Environment));
        assert_eq!(origin_of(false, false), None);
    }

    #[test]
    fn a_value_containing_equals_survives() {
        // Base64 and tokens routinely end in `=`.
        let values = parse("LUCIDA_COMFYUI_AUTH=Basic dXNlcjpwdw==\n");
        assert_eq!(
            values.get("LUCIDA_COMFYUI_AUTH").unwrap(),
            "Basic dXNlcjpwdw=="
        );
    }

    #[test]
    fn comments_and_blank_lines_are_ignored() {
        assert!(parse("# GOOGLE_API_KEY=nope\n\n   \n").is_empty());
    }

    #[test]
    fn the_template_only_offers_the_canonical_key_name() {
        let text = template();
        assert!(text.contains("#GEMINI_API_KEY="));

        // A retired name gets no slot. Offering one would invite writing a
        // value nothing reads — and a template is the worst place to learn that,
        // since it looks like the tool suggesting the name itself.
        for (retired, _) in RETIRED_KEYS {
            assert!(
                !text.contains(&format!("#{retired}=")),
                "the template offers the retired name {retired}"
            );
        }

        // Every line is inert until uncommented, so writing the file changes
        // nothing about how Lucida behaves.
        for line in text.lines().filter(|l| l.contains('=')) {
            assert!(
                line.trim_start().starts_with('#'),
                "template line is live: {line}"
            );
        }
    }

    /// The template must survive a round trip through the parser as a no-op —
    /// if a commented line ever parsed as a real one, `config --init` would
    /// silently blank out settings the environment was providing.
    #[test]
    fn the_template_parses_to_nothing() {
        assert!(parse(&template()).is_empty());
    }

    /// The replacement lands whole, and nothing is left beside it.
    ///
    /// The property that matters is the one a test cannot easily provoke — an
    /// interrupted write leaving the original intact — so what is checked here is
    /// the mechanism that provides it: the target is never the file being written
    /// to, and the staging file does not outlive the rename.
    #[test]
    fn a_replacement_is_staged_and_leaves_nothing_behind() {
        let dir = std::env::temp_dir().join(format!("lucida-write-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.env");

        std::fs::write(&path, "GEMINI_API_KEY=old\n").unwrap();
        write_replacing(&path, "GEMINI_API_KEY=new\n", true).unwrap();

        assert_eq!(
            std::fs::read_to_string(&path).unwrap(),
            "GEMINI_API_KEY=new\n"
        );

        let left: Vec<String> = std::fs::read_dir(&dir)
            .unwrap()
            .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
            .collect();
        assert_eq!(left, vec!["config.env"], "a staging file survived: {left:?}");

        // Private means private by the time the file has a name anyone reads,
        // rather than a chmod that follows the write of a key.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
            assert_eq!(mode & 0o777, 0o600, "mode {:o}", mode & 0o777);
        }

        std::fs::remove_dir_all(&dir).ok();
    }

    /// The one-line bug that made the config file unreachable on the platform
    /// that needs it most.
    ///
    /// Stock Windows sets `USERPROFILE` and no `HOME`, so reading only `HOME`
    /// left `search_paths()` empty — and an empty search list is not an error
    /// anywhere, it is simply a file that is never found. Pinned as a pure
    /// function because the Windows CI lane runs under git-bash, which sets
    /// `HOME` and therefore cannot represent this at all.
    #[test]
    fn the_home_directory_falls_back_to_the_windows_spelling() {
        let home = || Some(OsString::from("/home/someone"));
        let profile = || Some(OsString::from(r"C:\Users\someone"));

        // Unix: HOME, as before.
        assert_eq!(
            home_from(home(), None),
            Some(PathBuf::from("/home/someone"))
        );
        // Stock Windows: no HOME at all.
        assert_eq!(
            home_from(None, profile()),
            Some(PathBuf::from(r"C:\Users\someone"))
        );
        // Both (git-bash, which is why CI never saw the gap): HOME still wins,
        // so this changes nothing where it already worked.
        assert_eq!(
            home_from(home(), profile()),
            Some(PathBuf::from("/home/someone"))
        );
        // An exported-but-empty HOME falls through rather than resolving to it —
        // the same reading of "empty means absent" that `var` applies.
        assert_eq!(
            home_from(Some(OsString::new()), profile()),
            Some(PathBuf::from(r"C:\Users\someone"))
        );
        assert_eq!(home_from(None, None), None);
        assert_eq!(home_from(Some(OsString::new()), None), None);
    }

    /// Whatever the platform, there is somewhere to put a key.
    ///
    /// The failure this guards is not a wrong path but an *empty list*: no file
    /// found, nowhere for `config --init` to write, and `preferred_path()`
    /// returning `None` so the messages that tell someone where to put a key
    /// silently drop that half.
    #[test]
    fn some_config_location_is_always_offered() {
        // Uses the real environment deliberately: on any machine CI runs on,
        // at least one of HOME / USERPROFILE / XDG_CONFIG_HOME / APPDATA is set.
        assert!(
            preferred_path().is_some(),
            "no config location on {}: search_paths() is empty",
            std::env::consts::OS
        );
    }

    #[test]
    fn an_explicit_config_path_wins_outright() {
        // Uses the real environment, so pick a name nothing else sets.
        unsafe { std::env::set_var("LUCIDA_CONFIG", "/tmp/lucida-test-config.env") };
        let paths = search_paths();
        unsafe { std::env::remove_var("LUCIDA_CONFIG") };

        assert_eq!(paths, vec![PathBuf::from("/tmp/lucida-test-config.env")]);
    }
}