cartog 0.34.0

Code graph indexer for LLM coding agents. Map your codebase, navigate by graph.
Documentation
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
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
//! Tests for config loading, validation, and db-path resolution.

use crate::config::*;
use serial_test::serial;
use std::fs;
use std::path::{Path, PathBuf};

#[test]
fn test_expand_tilde_with_home() {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_else(|_| "/tmp".into());
    let expanded = expand_tilde(PathBuf::from("~/foo/bar"));
    assert_eq!(expanded, PathBuf::from(home).join("foo/bar"));
}

#[test]
fn unknown_sections_flags_typos_but_not_known_keys() {
    let raw: toml::value::Table =
        toml::from_str("[embeddings]\nprovider = \"ollama\"\n[database]\npath = \"x\"\n").unwrap();
    let unknown = unknown_sections(&raw);
    assert_eq!(unknown, vec!["embeddings"]);
}

#[test]
fn unknown_sections_empty_for_all_known() {
    let raw: toml::value::Table = toml::from_str(
        "[database]\npath = \"x\"\n[embedding]\nprovider = \"local\"\n[index]\nexclude = []\n",
    )
    .unwrap();
    assert!(unknown_sections(&raw).is_empty());
}

#[test]
fn validate_providers_accepts_known_values() {
    let config: CartogConfig =
        toml::from_str("[embedding]\nprovider = \"ollama\"\n[reranker]\nprovider = \"none\"\n")
            .unwrap();
    assert!(validate_providers(&config).is_ok());
}

#[test]
fn validate_providers_accepts_absent_provider() {
    let config = CartogConfig::default();
    assert!(validate_providers(&config).is_ok());
}

#[test]
fn validate_providers_rejects_unknown_embedding_provider() {
    let config: CartogConfig = toml::from_str("[embedding]\nprovider = \"ollma\"\n").unwrap();
    let err = validate_providers(&config).unwrap_err();
    assert!(
        err.contains("ollma"),
        "error should name the bad value: {err}"
    );
}

#[test]
fn validate_providers_rejects_unknown_reranker_provider() {
    let config: CartogConfig = toml::from_str("[reranker]\nprovider = \"bogus\"\n").unwrap();
    let err = validate_providers(&config).unwrap_err();
    assert!(
        err.contains("bogus"),
        "error should name the bad value: {err}"
    );
}

#[test]
fn read_config_rejects_unknown_provider() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join("config.toml");
    fs::write(&cfg_path, "[embedding]\nprovider = \"ollma\"\n").unwrap();
    assert!(read_config(&cfg_path).is_none());
}

/// A bare `~` must expand, like `~/x` does.
///
/// `--under '~'` (quoted, or from a script where the shell does not expand it)
/// silently matched nothing, while the MCP expander — which the comments claim
/// this one mirrors — expanded it to `$HOME` and searched everything. Same
/// argument, opposite behaviour across two surfaces.
#[test]
fn expand_tilde_expands_a_bare_tilde() {
    // Reads the real HOME rather than setting it: this crate's tests must not
    // mutate process-global env (see the test-isolation rules), and the
    // sibling `test_expand_tilde_with_home` uses the same approach.
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_else(|_| "/tmp".into());

    assert_eq!(
        expand_tilde(PathBuf::from("~")),
        PathBuf::from(&home),
        "a bare `~` is the home directory"
    );
    assert_eq!(
        expand_tilde(PathBuf::from("~/work")),
        PathBuf::from(&home).join("work"),
        "`~/x` keeps working"
    );
    // `~user` is another user's home, which this does not resolve — leaving it
    // alone is better than silently pointing at the wrong person's directory.
    assert_eq!(
        expand_tilde(PathBuf::from("~other/work")),
        PathBuf::from("~other/work"),
        "`~user` is not ours to expand"
    );
}

/// The tilde is a path component, so the platform's own separator works.
///
/// A `~/`-only string test expanded on unix and silently failed on Windows,
/// where the separator is `\` — and Windows is a shipped release target.
///
/// On unix this is tautological (`MAIN_SEPARATOR` is `/`, so both forms pass);
/// it earns its place on the Windows CI target, where the old form fails. A
/// hardcoded `~\work` would instead be wrong on unix, where `\` is a legal
/// filename character rather than a separator.
#[test]
fn expand_tilde_uses_the_platform_separator() {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_else(|_| "/tmp".into());
    let sep = std::path::MAIN_SEPARATOR;

    let expanded = expand_tilde(PathBuf::from(format!("~{sep}work")));
    assert_eq!(
        expanded,
        PathBuf::from(&home).join("work"),
        "`~{sep}work` must expand on this platform"
    );
}

#[test]
fn test_expand_tilde_no_tilde() {
    let p = PathBuf::from("/absolute/path");
    assert_eq!(expand_tilde(p.clone()), p);
}

#[test]
fn test_read_config_valid_toml() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join("config.toml");
    fs::write(&cfg_path, "[database]\npath = \"/tmp/test.db\"\n").unwrap();
    let cfg = read_config(&cfg_path).expect("should parse");
    assert_eq!(
        cfg.database.as_ref().unwrap().path.as_deref(),
        Some("/tmp/test.db")
    );
}

#[test]
fn test_read_config_missing_file_returns_none() {
    let result = read_config(Path::new("/nonexistent/path/config.toml"));
    assert!(result.is_none());
}

#[test]
fn test_read_config_invalid_toml_returns_none() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join("config.toml");
    fs::write(&cfg_path, "this is {{ not valid toml").unwrap();
    assert!(read_config(&cfg_path).is_none());
}

#[test]
fn test_read_config_empty_toml_returns_default() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join("config.toml");
    fs::write(&cfg_path, "").unwrap();
    let cfg = read_config(&cfg_path).expect("empty toml is valid");
    assert!(cfg.database.is_none());
}

#[test]
fn test_resolve_explicit_wins_over_config() {
    let cfg = CartogConfig {
        database: Some(DatabaseConfig {
            path: Some("/config/path.db".to_string()),
        }),
        ..Default::default()
    };
    let result = resolve_db_path(Some(PathBuf::from("/explicit/path.db")), &cfg);
    assert_eq!(result, PathBuf::from("/explicit/path.db"));
}

#[test]
fn test_resolve_config_path_used_when_no_explicit() {
    let cfg = CartogConfig {
        database: Some(DatabaseConfig {
            path: Some("/config/proj.db".to_string()),
        }),
        ..Default::default()
    };
    let result = resolve_db_path(None, &cfg);
    assert_eq!(result, PathBuf::from("/config/proj.db"));
}

#[test]
#[serial]
fn test_resolve_fallback_when_no_config_and_no_git() {
    let dir = tempfile::TempDir::new().unwrap();
    let canonical = dir.path().canonicalize().unwrap();
    let original = std::env::current_dir().unwrap();
    std::env::set_current_dir(dir.path()).unwrap();

    let result = resolve_db_path(None, &CartogConfig::default());
    std::env::set_current_dir(original).unwrap();

    assert_eq!(
        result,
        canonical
            .join(cartog_db::DB_DIR)
            .join(cartog_db::DB_FILENAME)
    );
}

#[test]
#[serial]
fn test_resolve_git_root_detection() {
    let dir = tempfile::TempDir::new().unwrap();
    let canonical_root = dir.path().canonicalize().unwrap();
    let git_dir = dir.path().join(".git");
    std::fs::create_dir(&git_dir).unwrap();
    let subdir = dir.path().join("subdir");
    std::fs::create_dir(&subdir).unwrap();

    let original = std::env::current_dir().unwrap();
    std::env::set_current_dir(&subdir).unwrap();

    let result = resolve_db_path(None, &CartogConfig::default());
    std::env::set_current_dir(original).unwrap();

    assert_eq!(
        result,
        canonical_root
            .join(cartog_db::DB_DIR)
            .join(cartog_db::DB_FILENAME)
    );
}

#[test]
#[serial]
fn test_resolve_prefers_new_layout_over_legacy() {
    let dir = tempfile::TempDir::new().unwrap();
    let canonical_root = dir.path().canonicalize().unwrap();
    std::fs::create_dir(dir.path().join(".git")).unwrap();
    // Both files exist — new layout wins.
    std::fs::create_dir(dir.path().join(cartog_db::DB_DIR)).unwrap();
    std::fs::write(
        dir.path()
            .join(cartog_db::DB_DIR)
            .join(cartog_db::DB_FILENAME),
        b"",
    )
    .unwrap();
    std::fs::write(dir.path().join(cartog_db::LEGACY_DB_FILE), b"").unwrap();

    let original = std::env::current_dir().unwrap();
    std::env::set_current_dir(dir.path()).unwrap();
    let result = resolve_db_path(None, &CartogConfig::default());
    std::env::set_current_dir(original).unwrap();

    assert_eq!(
        result,
        canonical_root
            .join(cartog_db::DB_DIR)
            .join(cartog_db::DB_FILENAME)
    );
}

#[test]
#[serial]
fn test_resolve_falls_back_to_legacy_db_file() {
    let dir = tempfile::TempDir::new().unwrap();
    let canonical_root = dir.path().canonicalize().unwrap();
    std::fs::create_dir(dir.path().join(".git")).unwrap();
    // Only legacy file exists — picks it up (and warns once).
    std::fs::write(dir.path().join(cartog_db::LEGACY_DB_FILE), b"").unwrap();

    let original = std::env::current_dir().unwrap();
    std::env::set_current_dir(dir.path()).unwrap();
    let result = resolve_db_path(None, &CartogConfig::default());
    std::env::set_current_dir(original).unwrap();

    assert_eq!(result, canonical_root.join(cartog_db::LEGACY_DB_FILE));
}

#[test]
fn validate_providers_accepts_openai() {
    let config: CartogConfig = toml::from_str("[embedding]\nprovider = \"openai\"\n").unwrap();
    assert!(validate_providers(&config).is_ok());
}

#[test]
fn lsp_override_parses_nested_table() {
    let toml_str = r#"
[lsp.dart]
command = ["docker", "run", "--rm", "-i", "-v", "${ROOT}:${ROOT}", "cartog-lsp-dart:stable"]
"#;
    let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
    let dart = &cfg.lsp.unwrap().langs["dart"];
    assert_eq!(dart.command[0], "docker");
    assert_eq!(dart.command.last().unwrap(), "cartog-lsp-dart:stable");
}

#[test]
fn to_lsp_overrides_flattens_to_argv_map() {
    let toml_str = r#"
[lsp.go]
command = ["gopls", "serve"]
"#;
    let cfg: CartogConfig = toml::from_str(toml_str).unwrap();
    let map = to_lsp_overrides(&cfg);
    assert_eq!(map["go"], vec!["gopls".to_string(), "serve".to_string()]);
}

#[test]
fn to_lsp_overrides_empty_when_absent() {
    assert!(to_lsp_overrides(&CartogConfig::default()).is_empty());
}

#[test]
fn read_config_rejects_empty_lsp_command() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    fs::write(&cfg_path, "[lsp.dart]\ncommand = []\n").unwrap();
    assert!(read_config(&cfg_path).is_none());
}

#[test]
fn read_config_rejects_unknown_lsp_field() {
    // deny_unknown_fields on LspLangConfig: a typo like `cmd` must fail.
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    fs::write(&cfg_path, "[lsp.dart]\ncmd = [\"x\"]\n").unwrap();
    assert!(read_config(&cfg_path).is_none());

    // `cmd` alone is a *missing field* error, which would pass even with
    // `deny_unknown_fields` off. Pair a valid `command` with a stray key so this
    // actually exercises unknown-field rejection.
    let stray = dir.path().join("stray.toml");
    fs::write(&stray, "[lsp.dart]\ncommand = [\"x\"]\nargz = 1\n").unwrap();
    assert!(
        read_config(&stray).is_none(),
        "a stray key alongside a valid `command` must still reject"
    );
}

#[test]
fn read_config_accepts_valid_lsp_block() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    fs::write(&cfg_path, "[lsp.go]\ncommand = [\"gopls\", \"serve\"]\n").unwrap();
    let cfg = read_config(&cfg_path).expect("valid lsp block parses");
    assert!(cfg.lsp.unwrap().langs.contains_key("go"));
}

#[cfg(feature = "lsp")]
#[test]
fn read_config_rejects_unknown_lsp_language() {
    // A typo like `[lsp.pytho]` must fail at config load, not at first LSP use.
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    fs::write(&cfg_path, "[lsp.pytho]\ncommand = [\"x\"]\n").unwrap();
    assert!(read_config(&cfg_path).is_none());
}

// ── Consent gate predicate ──

#[test]
#[serial]
fn allow_index_creation_refuses_fresh_repo() {
    let dir = tempfile::TempDir::new().unwrap();
    let absent = dir.path().join(".cartog").join("db.sqlite");
    let _guard = scopeguard(AUTO_INIT_ENV);
    std::env::remove_var(AUTO_INIT_ENV);
    assert!(
        !allow_index_creation(&absent, IndexConsent::Absent),
        "no config + no DB + no env must refuse"
    );
}

#[test]
fn allow_index_creation_allows_with_config_present() {
    let dir = tempfile::TempDir::new().unwrap();
    let absent = dir.path().join(".cartog").join("db.sqlite");
    assert!(allow_index_creation(&absent, IndexConsent::Granted));
}

#[test]
fn allow_index_creation_allows_with_existing_db() {
    let dir = tempfile::TempDir::new().unwrap();
    let db = dir.path().join("db.sqlite");
    std::fs::write(&db, b"").unwrap();
    assert!(allow_index_creation(&db, IndexConsent::Absent));
}

#[test]
#[serial]
fn allow_index_creation_stray_wal_without_main_file_is_gated() {
    // Keyed on the main DB file; a stray -wal alone is still "fresh".
    let dir = tempfile::TempDir::new().unwrap();
    let db = dir.path().join("db.sqlite");
    std::fs::write(dir.path().join("db.sqlite-wal"), b"").unwrap();
    let _guard = scopeguard(AUTO_INIT_ENV);
    std::env::remove_var(AUTO_INIT_ENV);
    assert!(!allow_index_creation(&db, IndexConsent::Absent));
}

#[test]
#[serial]
fn allow_index_creation_allows_with_auto_init_env() {
    let dir = tempfile::TempDir::new().unwrap();
    let absent = dir.path().join(".cartog").join("db.sqlite");
    let _guard = scopeguard(AUTO_INIT_ENV);
    std::env::set_var(AUTO_INIT_ENV, "1");
    assert!(allow_index_creation(&absent, IndexConsent::Absent));
}

#[test]
#[serial]
fn allow_index_creation_ignores_empty_auto_init_env() {
    let dir = tempfile::TempDir::new().unwrap();
    let absent = dir.path().join(".cartog").join("db.sqlite");
    let _guard = scopeguard(AUTO_INIT_ENV);
    std::env::set_var(AUTO_INIT_ENV, "");
    assert!(
        !allow_index_creation(&absent, IndexConsent::Absent),
        "an empty CARTOG_AUTO_INIT must not count as opt-in"
    );
}

/// Restore an env var to its pre-test value on drop, so a `set_var`/`remove_var`
/// in one `#[serial]` test can't leak into another.
fn scopeguard(key: &'static str) -> impl Drop {
    struct Restore {
        key: &'static str,
        prev: Option<String>,
    }
    impl Drop for Restore {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var(self.key, v),
                None => std::env::remove_var(self.key),
            }
        }
    }
    Restore {
        key,
        prev: std::env::var(key).ok(),
    }
}

/// An unknown key is a typo, not a reason to discard the file. Before this was
/// handled, `deny_unknown_fields` made one misspelling drop every other setting
/// AND revoke index-creation consent (`config_present` is false for `Rejected`),
/// so `cartog index` refused with "no .cartog.toml in this project".
#[test]
fn unknown_key_keeps_the_rest_of_the_config() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[database]\npath = \"/tmp/kept.db\"\n\n[rag]\nrerank_mx = 10\nrerank_max = 33\n",
    )
    .unwrap();

    let cfg = read_config(&cfg_path).expect("a stray key must not reject the whole config");
    assert_eq!(
        cfg.database.expect("[database] survives").path.as_deref(),
        Some("/tmp/kept.db"),
    );
    // A valid sibling in the *same* section survives too.
    assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(33));
}

#[test]
fn unknown_key_still_loads_so_consent_is_preserved() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(&cfg_path, "[security]\nredact_secretz = false\n").unwrap();
    // `Some` is what makes `ConfigLoad::Loaded` (= consent to create an index).
    assert!(
        read_config(&cfg_path).is_some(),
        "a typo must not revoke index-creation consent"
    );
}

#[test]
fn genuine_syntax_error_is_still_rejected() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(&cfg_path, "[database\npath = \"x\"\n").unwrap();
    assert!(read_config(&cfg_path).is_none());
}

#[test]
fn wrong_value_type_is_still_rejected() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(&cfg_path, "[index]\njobs = \"many\"\n").unwrap();
    assert!(read_config(&cfg_path).is_none());
}

#[test]
fn unknown_lsp_scalar_key_is_dropped_not_fatal() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[lsp]\nmax_concurrent_serverz = 2\n\n[lsp.rust]\ncommand = [\"rust-analyzer\"]\n",
    )
    .unwrap();
    let cfg = read_config(&cfg_path).expect("[lsp] typo must not reject the config");
    let lsp = cfg.lsp.expect("[lsp] survives");
    assert!(
        lsp.langs.contains_key("rust"),
        "per-language entry survives"
    );
    assert_eq!(lsp.max_concurrent_servers, None);
}

/// `[remote]` is exempt from the lenient unknown-key path: a mistyped key there
/// could silently redirect where the index is pushed or pulled.
#[test]
fn unknown_remote_key_stays_a_hard_rejection() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[remote]\nurl = \"s3://b/k\"\npathstyle = true\n",
    )
    .unwrap();
    assert!(
        read_config(&cfg_path).is_none(),
        "a stray [remote] key must reject the config, not be ignored"
    );
}

/// The lenient path keys off `toml`'s error text (`is_unknown_field_error`).
/// If a dep bump rewords it, typos silently revert to full rejection — which
/// also revokes index-creation consent. Fail loudly here instead.
#[test]
fn toml_still_reports_unknown_fields_in_the_format_we_parse() {
    let e = toml::from_str::<CartogConfig>("[security]\nredact_secretz = false\n")
        .expect_err("unknown field must error");
    assert!(
        e.to_string().contains("unknown field"),
        "toml error format changed — is_unknown_field_error is now dead: {e}"
    );
    // The salvage removes the key serde names, so the name must stay extractable.
    // If this breaks, every typo reverts to whole-file rejection (which also
    // revokes index consent) instead of dropping one key.
    assert_eq!(
        crate::config::repair::unknown_field_name(&e).as_deref(),
        Some("redact_secretz"),
        "toml no longer names the offending key in a parseable form: {e}"
    );
}

/// A stray key must be dropped from the section it was written in, never matched
/// by name across the tree. `[rag] path` once deleted `[database] path`, silently
/// pointing cartog at a different database.
#[test]
fn typo_does_not_delete_a_same_named_key_in_another_section() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[database]\npath = \"/tmp/kept.db\"\n\n[rag]\npath = \"oops\"\nrerank_max = 33\n",
    )
    .unwrap();
    let cfg = read_config(&cfg_path).expect("stray key must not reject the config");
    assert_eq!(
        cfg.database.expect("[database] survives").path.as_deref(),
        Some("/tmp/kept.db"),
        "a [rag] typo must not delete [database] path"
    );
    assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(33));
}

#[test]
fn typo_does_not_delete_embedding_provider_from_another_section() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[embedding]\nprovider = \"ollama\"\n\n[security]\nprovider = \"x\"\n",
    )
    .unwrap();
    let cfg = read_config(&cfg_path).expect("stray key must not reject the config");
    assert_eq!(
        cfg.embedding.expect("[embedding] survives").provider(),
        "ollama",
        "a [security] typo must not reset [embedding] provider to the default"
    );
}

/// Two typos in different sections: both siblings must survive. Exercises the
/// per-section pass more than once.
#[test]
fn multiple_typos_in_different_sections_all_resolve() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[database]\npath = \"/tmp/x.db\"\nbogus = 1\n\n[rag]\nrerank_max = 7\nalso_bogus = 2\n",
    )
    .unwrap();
    let cfg = read_config(&cfg_path).expect("stray keys must not reject the config");
    assert_eq!(
        cfg.database.expect("[database] survives").path.as_deref(),
        Some("/tmp/x.db")
    );
    assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(7));
}

/// Two typos in the *same* section must both go. The salvage used to remove one
/// candidate and restore it when the section still complained, so with two stray
/// keys neither removal ever "helped" and the whole file was rejected — after the
/// user had already been told the rest of the config still applied.
#[test]
fn two_typos_in_one_section_both_resolve() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(&cfg_path, "[rag]\nrerank_max = 7\nbogus1 = 1\nbogus2 = 2\n").unwrap();
    let cfg = read_config(&cfg_path).expect("two stray keys must not reject the config");
    assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(7));
}

/// The salvage must converge on any number of typos, not just two.
#[test]
fn many_typos_in_one_section_all_resolve() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    let strays: String = (0..12).map(|i| format!("bogus{i} = {i}\n")).collect();
    std::fs::write(&cfg_path, format!("[rag]\nrerank_max = 7\n{strays}")).unwrap();
    let cfg = read_config(&cfg_path).expect("many stray keys must not reject the config");
    assert_eq!(cfg.rag.expect("[rag] survives").rerank_max, Some(7));
}

/// Every salvageable section must actually be wired into the dispatch. A section
/// added to `KNOWN_CONFIG_SECTIONS` but forgotten there silently reverts to
/// whole-file rejection while every sibling section salvages.
#[test]
fn every_known_section_salvages_a_stray_key() {
    // `remote` and `lsp` are the deliberate exemptions: a stray key there could
    // redirect where data is pushed or which process is spawned.
    const STRICT: &[&str] = &["remote", "lsp"];
    for section in KNOWN_CONFIG_SECTIONS.iter().filter(|s| !STRICT.contains(s)) {
        let dir = tempfile::TempDir::new().unwrap();
        let cfg_path = dir.path().join(".cartog.toml");
        std::fs::write(&cfg_path, format!("[{section}]\nbogus_key = 1\n")).unwrap();
        assert!(
            read_config(&cfg_path).is_some(),
            "[{section}] is in KNOWN_CONFIG_SECTIONS but has no salvage arm, \
             so a typo there rejects the whole file"
        );
    }
}

/// `[lsp.<lang>]` stays strict: the argv there spawns a process, so a stray key
/// must reject rather than be silently dropped.
#[test]
fn unknown_lsp_lang_key_stays_a_hard_rejection() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[lsp.rust]\ncommand = [\"rust-analyzer\"]\nargz = 1\n",
    )
    .unwrap();
    assert!(
        read_config(&cfg_path).is_none(),
        "a stray [lsp.<lang>] key must reject the config"
    );
}

/// The `[remote]` boundary must not be defeatable from another section. A typo
/// named like a remote field once deleted the REAL `[remote] endpoint`, which
/// makes `cartog push` fall back to AWS's default host instead of the user's
/// private endpoint.
#[test]
fn typo_elsewhere_never_deletes_a_remote_key() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[remote]\nurl = \"s3://team/idx\"\nendpoint = \"https://minio.internal\"\n\
         \n[security]\nendpoint = \"typo\"\n",
    )
    .unwrap();
    let cfg = read_config(&cfg_path).expect("stray [security] key must not reject the config");
    let remote = cfg.remote.expect("[remote] survives");
    assert_eq!(
        remote.endpoint.as_deref(),
        Some("https://minio.internal"),
        "a typo in another section must never delete [remote] endpoint"
    );
    assert_eq!(remote.url.as_deref(), Some("s3://team/idx"));
}

/// `LSP_SCALAR_KEYS` hand-lists `LspConfig`'s non-flattened fields (it can't use
/// `deny_unknown_fields` — `#[serde(flatten)]` forbids it). If a new scalar field
/// is added to `LspConfig` and not to that list, it would be silently stripped as
/// a typo. Assert every listed key round-trips, so the two can't drift apart.
#[test]
fn lsp_scalar_keys_are_all_real_lsp_config_fields() {
    for key in LSP_SCALAR_KEYS {
        let dir = tempfile::TempDir::new().unwrap();
        let cfg_path = dir.path().join(".cartog.toml");
        // A usize-valued scalar covers today's only entry; extend if a
        // non-numeric scalar is ever added.
        std::fs::write(&cfg_path, format!("[lsp]\n{key} = 2\n")).unwrap();
        let cfg = read_config(&cfg_path)
            .unwrap_or_else(|| panic!("[lsp] {key} must parse — is it a real LspConfig field?"));
        let lsp = cfg
            .lsp
            .unwrap_or_else(|| panic!("[lsp] section must survive for key {key}"));
        assert!(
            !lsp.langs.contains_key(*key),
            "{key} was routed into the per-language map instead of a real field \
             — LSP_SCALAR_KEYS and LspConfig have drifted"
        );
    }
}

/// A stray key inside a provider sub-table must not take the sub-table with it.
/// Deleting `[embedding.openai]` silently moved the endpoint from a self-hosted
/// server to the public API and changed which env var supplies the key.
#[test]
fn typo_in_a_provider_subtable_keeps_the_subtable() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    std::fs::write(
        &cfg_path,
        "[embedding]\nprovider = \"openai\"\n\n[embedding.openai]\n\
         base_url = \"http://good.example/v1\"\napi_key_env = \"MY_CUSTOM_KEY\"\n\
         base_urll = \"typo\"\n",
    )
    .unwrap();
    let cfg = read_config(&cfg_path).expect("stray sub-table key must not reject the config");
    let openai = cfg
        .embedding
        .expect("[embedding] survives")
        .openai
        .expect("[embedding.openai] must survive a typo inside it");
    assert_eq!(
        openai.base_url(),
        "http://good.example/v1",
        "a typo must not repoint the endpoint at the public API"
    );
    assert_eq!(openai.api_key_env(), "MY_CUSTOM_KEY");
}

// ── Consent is derived from file presence, not parse success ──
//
// Deriving it from parse success conflated "which settings apply" with "may
// cartog index here", so one typo made `cartog index` refuse with
// "no .cartog.toml in this project" while the user was looking at one.

#[test]
fn consent_is_absent_when_no_config_file_exists() {
    assert_eq!(ConfigLoad::Missing.consent(), IndexConsent::Absent);
}

#[test]
fn consent_is_granted_by_a_loaded_config() {
    let loaded = ConfigLoad::Loaded {
        config: CartogConfig::default(),
        path: PathBuf::from("/tmp/.cartog.toml"),
    };
    assert_eq!(loaded.consent(), IndexConsent::Granted);
}

#[test]
fn consent_is_granted_by_a_rejected_config_too() {
    let rejected = ConfigLoad::Rejected {
        path: PathBuf::from("/tmp/.cartog.toml"),
    };
    assert_eq!(
        rejected.consent(),
        IndexConsent::Granted,
        "a config file that failed to parse is still an opt-in: the file exists"
    );
}

#[test]
fn a_rejected_config_still_allows_index_creation() {
    let dir = tempfile::TempDir::new().unwrap();
    let absent_db = dir.path().join("nope.sqlite");
    let rejected = ConfigLoad::Rejected {
        path: dir.path().join(".cartog.toml"),
    };
    assert!(
        allow_index_creation(&absent_db, rejected.consent()),
        "a broken config must not be reported as no config"
    );
}

#[test]
fn settings_still_fall_back_to_defaults_when_rejected() {
    // Consent is decoupled from settings, not merged with them: a rejected
    // config grants consent but must NOT leak half-parsed values.
    let rejected = ConfigLoad::Rejected {
        path: PathBuf::from("/tmp/.cartog.toml"),
    };
    let cfg = rejected.config_or_default();
    assert!(cfg.database.is_none());
    assert!(cfg.embedding.is_none());
    assert!(cfg.index.is_none());
}

#[test]
fn is_granted_matches_the_variant() {
    assert!(IndexConsent::Granted.is_granted());
    assert!(!IndexConsent::Absent.is_granted());
}

// ── IndexCreation: one gate for `main` and `doctor` ──

/// Clears `CARTOG_AUTO_INIT` for a test and restores it on drop. Restoring on
/// drop (not at the end of the body) keeps a panicking test from leaking the
/// change into the next one.
struct AutoInitGuard(Option<String>);

impl AutoInitGuard {
    fn clearing() -> Self {
        let prev = std::env::var(AUTO_INIT_ENV).ok();
        std::env::remove_var(AUTO_INIT_ENV);
        Self(prev)
    }
}

impl Drop for AutoInitGuard {
    fn drop(&mut self) {
        match self.0.take() {
            Some(v) => std::env::set_var(AUTO_INIT_ENV, v),
            None => std::env::remove_var(AUTO_INIT_ENV),
        }
    }
}

/// An explicit `--db` settles the location, so a rejected config no longer
/// blocks creation. `doctor` re-derived this predicate by hand and dropped the
/// override term, so it could report a db-path-unknown state for a run that
/// would have succeeded.
#[test]
fn explicit_db_override_lifts_the_unknown_db_path_refusal() {
    let dir = tempfile::TempDir::new().unwrap();
    let absent = dir.path().join(".cartog").join("db.sqlite");
    let over = dir.path().join("explicit.db");

    assert_eq!(
        IndexCreation::resolve(&absent, IndexConsent::Granted, true, None),
        IndexCreation::RefusedUnknownDbPath,
        "a rejected config with no override must not guess the db path"
    );
    assert_eq!(
        IndexCreation::resolve(&absent, IndexConsent::Granted, true, Some(&over)),
        IndexCreation::Allowed,
        "an explicit --db settles the location the rejected config left unknown"
    );
}

/// A rejected config still grants *consent* — the file existing is the opt-in —
/// so the refusal must be the db-path one, which carries its own message, not
/// the generic "no .cartog.toml" that would tell a user their file isn't there.
#[test]
#[serial]
fn rejected_config_refuses_on_db_path_not_on_consent() {
    // The `Absent` case falls through to `allow_index_creation`, which reads
    // CARTOG_AUTO_INIT — a set var in the ambient environment turns the expected
    // refusal into `Allowed`. The other assertions return before that call.
    let _guard = AutoInitGuard::clearing();
    let dir = tempfile::TempDir::new().unwrap();
    let absent = dir.path().join(".cartog").join("db.sqlite");
    assert_eq!(
        IndexCreation::resolve(&absent, IndexConsent::Granted, true, None),
        IndexCreation::RefusedUnknownDbPath
    );
    assert_eq!(
        IndexCreation::resolve(&absent, IndexConsent::Absent, false, None),
        IndexCreation::RefusedNoConsent,
        "no config at all is the generic no-consent case"
    );
}

/// An existing DB settles the location even when the config is rejected.
#[test]
fn existing_db_lifts_the_unknown_db_path_refusal() {
    let dir = tempfile::TempDir::new().unwrap();
    let db = dir.path().join("db.sqlite");
    std::fs::write(&db, b"").unwrap();
    assert_eq!(
        IndexCreation::resolve(&db, IndexConsent::Granted, true, None),
        IndexCreation::Allowed
    );
}

// ── `[project]` validation and salvage ──────────────────────────────────────

/// Regression: `[project]` missing from `KNOWN_CONFIG_SECTIONS` would nag
/// `unknown config key 'project'` on every interactive command while the
/// section parsed fine — a silent-nag bug.
#[test]
fn project_is_a_known_config_section() {
    let raw: toml::value::Table =
        toml::from_str("[project]\nname = \"billing-service\"\n").unwrap();
    assert!(unknown_sections(&raw).is_empty());
}

#[test]
fn validate_project_rejects_an_over_length_name() {
    let config: CartogConfig =
        toml::from_str(&format!("[project]\nname = \"{}\"\n", "n".repeat(101))).unwrap();

    let err = validate_project(&config).expect_err("101 chars must be rejected");

    assert!(
        err.contains("[project] name"),
        "message names the field: {err}"
    );
    assert!(err.contains("100"), "message names the limit: {err}");
    assert!(err.contains("101"), "message names what was given: {err}");
}

#[test]
fn validate_project_rejects_an_over_length_description() {
    let config: CartogConfig = toml::from_str(&format!(
        "[project]\ndescription = \"{}\"\n",
        "d".repeat(312)
    ))
    .unwrap();

    let err = validate_project(&config).expect_err("312 chars must be rejected");

    assert_eq!(
        err,
        "[project] description exceeds 280 characters (got 312)"
    );
}

#[test]
fn validate_project_accepts_a_value_exactly_at_the_cap() {
    let config: CartogConfig = toml::from_str(&format!(
        "[project]\ndescription = \"{}\"\n",
        "d".repeat(280)
    ))
    .unwrap();

    assert!(validate_project(&config).is_ok());
}

/// A multi-line description breaks every single-line rendering surface.
#[test]
fn validate_project_rejects_a_newline_in_the_description() {
    let config: CartogConfig =
        toml::from_str("[project]\ndescription = \"line one\\nline two\"\n").unwrap();

    let err = validate_project(&config).expect_err("a newline must be rejected");

    assert!(
        err.contains("[project] description") && err.contains("control character"),
        "{err}"
    );
}

#[test]
fn validate_project_rejects_a_control_character_in_the_name() {
    let config: CartogConfig = toml::from_str("[project]\nname = \"svc\\u0007billing\"\n").unwrap();

    let err = validate_project(&config).expect_err("a BEL must be rejected");

    assert!(err.contains("[project] name"), "{err}");
}

#[test]
fn validate_project_rejects_a_tab_in_the_description() {
    let config: CartogConfig = toml::from_str("[project]\ndescription = \"one\\ttwo\"\n").unwrap();

    assert!(validate_project(&config).is_err());
}

/// A blank value is a mistake, not a way to spell "unset" — omitting the key is.
#[test]
fn validate_project_rejects_a_whitespace_only_name() {
    let config: CartogConfig = toml::from_str("[project]\nname = \"   \"\n").unwrap();

    let err = validate_project(&config).expect_err("whitespace-only must be rejected");

    assert!(
        err.contains("[project] name") && err.contains("empty"),
        "{err}"
    );
}

#[test]
fn validate_project_accepts_an_absent_section() {
    assert!(validate_project(&CartogConfig::default()).is_ok());
}

/// A cap measured in chars, not bytes: a short accented name must not be
/// rejected for being multi-byte.
#[test]
fn validate_project_counts_chars_not_bytes() {
    let config: CartogConfig =
        toml::from_str(&format!("[project]\nname = \"{}\"\n", "é".repeat(100))).unwrap();

    assert!(validate_project(&config).is_ok());
}

/// An over-length value is a rejected config, not a silent truncation.
#[test]
fn read_config_rejects_an_over_length_project_description() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    fs::write(
        &cfg_path,
        format!("[project]\ndescription = \"{}\"\n", "d".repeat(281)),
    )
    .unwrap();

    assert!(read_config(&cfg_path).is_none());
}

/// A `descriptoin` typo must cost the description, not the index: the config
/// still loads, every other setting applies, and consent stays granted.
#[test]
fn unknown_project_key_is_salvaged_without_costing_the_index() {
    let dir = tempfile::TempDir::new().unwrap();
    let cfg_path = dir.path().join(".cartog.toml");
    fs::write(
        &cfg_path,
        "[project]\nname = \"billing-service\"\ndescriptoin = \"typo\"\n[security]\nredact_secrets = false\n",
    )
    .unwrap();

    let cfg = read_config(&cfg_path).expect("a typo must not reject the whole file");

    let project = cfg.project.as_ref().expect("section survives the salvage");
    assert_eq!(project.name(), Some("billing-service"));
    assert_eq!(project.description(), None);
    assert!(
        !cfg.security.as_ref().unwrap().redact_secrets(),
        "a sibling section's setting still applies"
    );
    assert_eq!(
        ConfigLoad::Loaded {
            config: cfg,
            path: cfg_path
        }
        .consent(),
        IndexConsent::Granted
    );
}

// ── resolve_project_at: root-relative resolution for `projects scan` ──

#[test]
fn resolve_project_at_reads_the_named_roots_own_config_not_the_cwd() {
    // `projects scan` visits many roots in one process, so resolution must not
    // key off the working directory the way `resolve_db_path` does.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path();
    fs::write(
        root.join(".cartog.toml"),
        "[project]\nname = \"Alpha\"\ndescription = \"Does alpha things.\"\n",
    )
    .unwrap();

    let resolved = resolve_project_at(root);

    assert_eq!(
        resolved.declared,
        DeclaredAtRoot::Known {
            name: Some("Alpha".to_string()),
            description: Some("Does alpha things.".to_string()),
        }
    );
    assert_eq!(
        resolved.db_path,
        root.join(cartog_db::DB_DIR).join(cartog_db::DB_FILENAME),
        "the default db path must be resolved under the named root"
    );
}

#[test]
fn resolve_project_at_treats_a_relative_database_path_as_relative_to_that_root() {
    // A relative `[database] path` declared by a scanned root means "inside
    // that root". Joining it to the scanning process's cwd instead would point
    // the registry row at a database that does not exist.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path();
    fs::write(
        root.join(".cartog.toml"),
        "[database]\npath = \"custom/g.db\"\n",
    )
    .unwrap();

    assert_eq!(resolve_project_at(root).db_path, root.join("custom/g.db"));
}

#[test]
fn resolve_project_at_still_resolves_a_db_path_when_the_config_is_unreadable() {
    // A rejected config is not a reason to claim the project has no index —
    // the same rule the consent gate applies.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path();
    fs::write(root.join(".cartog.toml"), "this is not = = valid toml\n").unwrap();

    let resolved = resolve_project_at(root);

    assert_eq!(
        resolved.db_path,
        root.join(cartog_db::DB_DIR).join(cartog_db::DB_FILENAME)
    );
    // `Unreadable`, not `Known { name: None }`: a config that fails to parse
    // declares *nothing*, and a writer conflating the two erases a name and
    // description an earlier working config stored.
    assert_eq!(
        resolved.declared,
        DeclaredAtRoot::Unreadable,
        "a rejected config must be distinguishable from one declaring nothing"
    );
}

#[test]
fn resolve_project_at_declares_nothing_for_a_root_with_no_config() {
    let dir = tempfile::TempDir::new().unwrap();
    let resolved = resolve_project_at(dir.path());

    // Absent, not rejected: there is nothing to fail to parse, so the values
    // are legitimately known to be unset.
    assert_eq!(
        resolved.declared,
        DeclaredAtRoot::Known {
            name: None,
            description: None,
        }
    );
}