loctree 0.13.0

Structural code intelligence for AI agents. Scan once, query everything.
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
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::context_render::current_iso_timestamp;
use crate::pack::ContextPack;

pub const CONTEXT_ATLAS_PROTOCOL: &str = "loctree.context_atlas.v1";
pub const CONTEXT_ATLAS_DIR: &str = "context-atlas";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextAtlasManifest {
    pub protocol: String,
    pub status: String,
    pub project: String,
    pub snapshot: String,
    pub generated_at: String,
    pub atlas_dir: String,
    pub manifest: String,
    pub manifest_json: String,
    pub recommended_start: String,
    pub cards: Vec<ContextAtlasCard>,
    pub message: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextAtlasCard {
    pub id: String,
    pub title: String,
    pub path: String,
    pub lines: usize,
    pub bytes: usize,
    pub why: String,
    pub saves_you_from: String,
    /// True when the card body was capped to the per-card line budget and a
    /// complete sibling artifact was written. The manifest and on-card markers
    /// surface this so an agent never reads a clipped card as canonical truth.
    #[serde(default)]
    pub truncated: bool,
    /// Lines dropped from the card body when capped (0 when the card is whole).
    #[serde(default)]
    pub dropped_lines: usize,
    /// Relative filename of the complete sibling artifact (e.g.
    /// `01-structural-map.full.json`) written when the card was truncated.
    #[serde(default)]
    pub full_path: Option<String>,
    /// Line count of the complete sibling JSON payload. Present only for
    /// truncated cards, where `lines` is the materialized `.md` card length.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub full_payload_lines: Option<usize>,
}

impl ContextAtlasManifest {
    pub fn pointer_payload(&self) -> serde_json::Value {
        json!({
            "protocol": self.protocol,
            "status": self.status,
            "project": self.project,
            "snapshot": self.snapshot,
            "atlas_dir": self.atlas_dir,
            "manifest": self.manifest,
            "manifest_json": self.manifest_json,
            "recommended_start": self.recommended_start,
            "cards": self.cards,
            "message": self.message,
        })
    }

    pub fn render_cli_summary(&self) -> String {
        let total_lines: usize = self.cards.iter().map(|card| card.lines).sum();
        let mut out = String::new();
        out.push_str("╭─ Loctree Context Atlas ─────────────────────────────────────────────╮\n");
        out.push_str("│ Repo understanding materialized as small, named cards.              │\n");
        out.push_str("╰─────────────────────────────────────────────────────────────────────╯\n\n");
        out.push_str("Status: ready\n");
        out.push_str(&format!("Project: {}\n", self.project));
        out.push_str(&format!("Snapshot: {}\n", self.snapshot));
        out.push_str(&format!("Atlas dir: {}\n", self.atlas_dir));
        out.push_str(&format!("Start here: {}\n", self.manifest));
        out.push_str(&format!(
            "Cards: {} cards, {} readable lines\n\n",
            self.cards.len(),
            total_lines
        ));
        out.push_str("Recommended reading path:\n");
        for (idx, card) in self.cards.iter().enumerate() {
            out.push_str(&format!(
                "  {}. {}  ({})\n     Why: {}\n     Saves you from: {}\n",
                idx,
                card.path,
                card_line_label(card),
                card.why,
                card.saves_you_from
            ));
            if card.truncated {
                out.push_str(&format!(
                    "     ⚠ Partial: {} payload line(s) capped — read complete payload at {}\n",
                    card.dropped_lines,
                    card.full_path
                        .as_deref()
                        .unwrap_or("the sibling .full.json")
                ));
            }
        }
        out.push('\n');
        out.push_str("Completeness cue:\n");
        out.push_str(&self.message);
        out.push('\n');
        out.push_str("Tip: use `loct context --full --json` for the full machine-readable ContextPack, or `loct context --full --markdown` for the full human-readable pack.\n");
        out
    }
}

pub const ATLAS_REPO_DIR: &str = ".loctree";

pub fn atlas_dir_for_project(project_root: &Path) -> PathBuf {
    project_root.join(ATLAS_REPO_DIR).join(CONTEXT_ATLAS_DIR)
}

pub fn materialize_context_atlas(
    pack: &ContextPack,
    project_root: &Path,
    atlas_dir: Option<&Path>,
) -> io::Result<ContextAtlasManifest> {
    let atlas_dir = atlas_dir
        .map(Path::to_path_buf)
        .unwrap_or_else(|| atlas_dir_for_project(project_root));
    fs::create_dir_all(&atlas_dir)?;

    let project = pack
        .project
        .canonical_root
        .clone()
        .unwrap_or_else(|| project_root.display().to_string());
    let snapshot = snapshot_label(pack);
    let generated_at = current_iso_timestamp();

    let specs = vec![
        CardSpec {
            id: "core",
            title: "Core Map",
            filename: "00-core-map.md",
            why: "Repo identity, current risk, authority labels, safe next commands.",
            saves: "wrong project state, stale assumptions, unsafe first actions",
            body: render_core_card(pack),
        },
        CardSpec {
            id: "structural",
            title: "Structural Map",
            filename: "01-structural-map.md",
            why: "Files, symbols, imports, consumers, entrypoints; read before edits/refactors.",
            saves: "missed consumers, wrong impact, blind dependency edits",
            body: render_json_card(
                pack,
                "01-structural-map.md",
                "Structural Map",
                "This card contains dependency and symbol topology for the selected scope.",
                "This Structural Map does not include runtime behavior, env contracts, or verification gates. Read `02-runtime-map.md` and `04-verification-gates.md` before changing behavior.",
                json!(&pack.structural),
            ),
        },
        CardSpec {
            id: "runtime",
            title: "Runtime Map",
            filename: "02-runtime-map.md",
            why: "Runtime behavior, framework hints, env contracts, reachability.",
            saves: "wrong tests, hidden runtime coupling, config mistakes",
            body: render_json_card(
                pack,
                "02-runtime-map.md",
                "Runtime Map",
                "This card contains runtime signals derived from semantic facts and framework bridges.",
                "This Runtime Map does not include prior decisions or full risk history. Read `03-memory-trail.md` when continuing work and `05-risk-register.md` before release decisions.",
                json!(&pack.runtime),
            ),
        },
        CardSpec {
            id: "memory",
            title: "Memory Trail",
            filename: "03-memory-trail.md",
            why: "Prior decisions, outcomes, tasks, and AICX continuity when available.",
            saves: "repeated work, forgotten decisions, reimplemented tasks",
            body: render_json_card(
                pack,
                "03-memory-trail.md",
                "Memory Trail",
                "This card contains continuity memory from AICX if the overlay is available.",
                "This Memory Trail does not replace repo-verified facts. Re-check structural/runtime cards before editing.",
                json!(&pack.memory),
            ),
        },
        CardSpec {
            id: "verification",
            title: "Verification Gates",
            filename: "04-verification-gates.md",
            why: "Commands and likely tests most relevant to validate changes.",
            saves: "wrong validation path, skipped downstream checks, false confidence",
            body: render_verification_card(pack),
        },
        CardSpec {
            id: "risk",
            title: "Risk Register",
            filename: "05-risk-register.md",
            why: "Hotspots, cache/snapshot health, stale assumptions, next risk-reducing actions.",
            saves: "release blockers, high fan-in surprises, stale-cache decisions",
            body: render_json_card(
                pack,
                "05-risk-register.md",
                "Risk Register",
                "This card collects risk signals and recommended actions for the current context scope.",
                "This Risk Register does not include full source content. Use `loct slice`/`loct impact` for exact file-level surgery.",
                json!({ "risk": &pack.risk, "action": &pack.action, "authority": &pack.authority }),
            ),
        },
    ];

    let mut cards = Vec::new();
    for spec in specs {
        let path = atlas_dir.join(spec.filename);
        fs::write(&path, spec.body.content.as_bytes())?;
        // When the body was capped, write the complete payload to a concrete
        // sibling artifact so the on-card/manifest markers point at a real file
        // (not just the whole-pack `loct context --full --json` regeneration).
        let (truncated, dropped_lines, full_path, full_payload_lines) = match spec.body.overflow {
            Some(overflow) => {
                let full_artifact = atlas_dir.join(&overflow.full_filename);
                let mut full_json = overflow.full_json;
                if !full_json.ends_with('\n') {
                    full_json.push('\n');
                }
                fs::write(&full_artifact, full_json.as_bytes())?;
                (
                    true,
                    overflow.dropped,
                    Some(overflow.full_filename),
                    Some(overflow.full_payload_lines),
                )
            }
            None => (false, 0, None, None),
        };
        cards.push(ContextAtlasCard {
            id: spec.id.to_string(),
            title: spec.title.to_string(),
            path: spec.filename.to_string(),
            lines: line_count(&spec.body.content),
            bytes: spec.body.content.len(),
            why: spec.why.to_string(),
            saves_you_from: spec.saves.to_string(),
            truncated,
            dropped_lines,
            full_path,
            full_payload_lines,
        });
    }

    let manifest_path = atlas_dir.join("manifest.md");
    let manifest_json_path = atlas_dir.join("manifest.json");
    let receipt_path = atlas_dir.join("receipt.json");

    let mut manifest = ContextAtlasManifest {
        protocol: CONTEXT_ATLAS_PROTOCOL.to_string(),
        status: "atlas_ready".to_string(),
        project,
        snapshot,
        generated_at,
        atlas_dir: atlas_dir.display().to_string(),
        manifest: manifest_path.display().to_string(),
        manifest_json: manifest_json_path.display().to_string(),
        recommended_start: atlas_dir.join("00-core-map.md").display().to_string(),
        cards,
        message: "This atlas contains the repo understanding an agent would otherwise rediscover manually. Start with manifest.md, then read the recommended cards; broad repo-level answers are incomplete until core, structural, and runtime are read.".to_string(),
    };

    let manifest_md = render_manifest(&manifest);
    fs::write(&manifest_path, manifest_md.as_bytes())?;
    manifest.manifest = manifest_path.display().to_string();
    fs::write(
        &manifest_json_path,
        serde_json::to_string_pretty(&manifest).map_err(io::Error::other)?,
    )?;
    fs::write(
        &receipt_path,
        serde_json::to_string_pretty(&json!({
            "protocol": CONTEXT_ATLAS_PROTOCOL,
            "generated_at": manifest.generated_at,
            "project": manifest.project,
            "snapshot": manifest.snapshot,
            "cards": manifest.cards.iter().map(|card| &card.path).collect::<Vec<_>>()
        }))
        .map_err(io::Error::other)?,
    )?;

    Ok(manifest)
}

struct CardSpec {
    id: &'static str,
    title: &'static str,
    filename: &'static str,
    why: &'static str,
    saves: &'static str,
    body: CardBody,
}

/// A rendered atlas card body plus the optional overflow receipt produced when
/// the JSON payload exceeded the per-card cap.
struct CardBody {
    /// Card content written to `<filename>` (capped to the per-card budget).
    content: String,
    /// Present only when the payload was capped; carries the complete payload
    /// so the materializer can write a concrete `<stem>.full.json` sibling.
    overflow: Option<CardOverflow>,
}

struct CardOverflow {
    /// Complete (uncapped) pretty JSON payload.
    full_json: String,
    /// Line count of the complete sibling JSON payload.
    full_payload_lines: usize,
    /// Lines dropped from the card body.
    dropped: usize,
    /// Concrete sibling artifact filename (e.g. `01-structural-map.full.json`).
    full_filename: String,
}

fn snapshot_label(pack: &ContextPack) -> String {
    let branch = pack.project.branch.as_deref().unwrap_or("unknown");
    let commit = pack.project.commit.as_deref().unwrap_or("unknown");
    format!("{}@{}", branch, commit)
}

fn atlas_freshness_line(pack: &ContextPack) -> String {
    if pack.risk.stale_snapshot {
        format!(
            "STALE - card snapshot {} lags live git state; refresh with `loct context --full` before relying on this card.",
            snapshot_label(pack)
        )
    } else if pack.risk.dirty_worktree {
        "DIRTY - card was generated from a dirty worktree; verify changed files before relying on this card."
            .to_string()
    } else {
        "fresh - card matches the loaded snapshot authority.".to_string()
    }
}

fn render_manifest(manifest: &ContextAtlasManifest) -> String {
    let mut out = String::new();
    out.push_str("# Loctree Context Atlas\n\n");
    out.push_str(&format!("Project: `{}`\n", manifest.project));
    out.push_str(&format!("Snapshot: `{}`\n", manifest.snapshot));
    out.push_str(&format!("Generated: `{}`\n\n", manifest.generated_at));
    out.push_str("This atlas is precomputed repository understanding. It contains the repo map an agent would otherwise have to rediscover manually through search/open cycles.\n\n");
    out.push_str("Tokens are cheaper than wrong assumptions.\n\n");
    out.push_str("## Recommended Reading Path\n\n");
    out.push_str("| Step | File | Lines | Why read it | Saves you from |\n");
    out.push_str("|---:|---|---:|---|---|\n");
    for (idx, card) in manifest.cards.iter().enumerate() {
        out.push_str(&format!(
            "| {} | `{}` | {} | {} | {} |\n",
            idx,
            card.path,
            card_line_label(card),
            card.why,
            card.saves_you_from
        ));
    }
    out.push_str("\n## Completeness\n\n");
    out.push_str("Current reading state: `0/");
    out.push_str(&manifest.cards.len().to_string());
    out.push_str("` context cards read.\n");
    out.push_str("A broad repo-level answer is incomplete until at least `00-core-map.md`, `01-structural-map.md`, and `02-runtime-map.md` have been read.\n");

    let partial: Vec<&ContextAtlasCard> = manifest.cards.iter().filter(|c| c.truncated).collect();
    if !partial.is_empty() {
        out.push_str("Partial-card completeness is stricter: the atlas is not fully read until each listed `.full.json` sibling has been opened too.\n");
        out.push_str("\n## Partial cards\n\n");
        out.push_str("These cards were capped to the per-card screen budget. Do not treat them as exhaustive — read the complete payload at the sibling artifact before relying on the card:\n\n");
        for card in partial {
            let full = card.full_path.as_deref().unwrap_or("(sibling .full.json)");
            out.push_str(&format!(
                "- `{}` — {}; {} payload line(s) dropped. Complete payload: `{}`\n",
                card.path,
                card_line_label(card),
                card.dropped_lines,
                full
            ));
        }
    }
    out
}

fn card_line_label(card: &ContextAtlasCard) -> String {
    if !card.truncated {
        return format!("{} lines", card.lines);
    }

    match card.full_payload_lines {
        Some(full_payload_lines) => format!(
            "{} materialized lines / {} full-payload lines âš  partial",
            card.lines, full_payload_lines
        ),
        None => format!("{} materialized lines âš  partial", card.lines),
    }
}

fn render_core_card(pack: &ContextPack) -> CardBody {
    render_json_card(
        pack,
        "00-core-map.md",
        "Core Map",
        "This card tells you where you are, what is risky, and what actions are safe next.",
        "This Core Map does not include dependency consumers, runtime entrypoints, or prior decisions. For code changes, read `01-structural-map.md` next.",
        json!({
            "schema_version": pack.schema_version,
            "project": &pack.project,
            "risk": &pack.risk,
            "action": &pack.action,
            "authority": &pack.authority,
        }),
    )
}

fn render_verification_card(pack: &ContextPack) -> CardBody {
    render_json_card(
        pack,
        "04-verification-gates.md",
        "Verification Gates",
        "This card lists likely verification commands and tests derived from the current context.",
        "This Verification Gates card does not prove correctness by itself. Run the commands before release or submit.",
        json!({
            "next_safe_commands": &pack.action.next_safe_commands,
            "likely_tests": &pack.action.likely_tests,
            "risk": &pack.risk,
        }),
    )
}

/// Hard cap on lines for each Atlas card body (JSON payload only — frame
/// lines like the header / lead / footer are added on top). Mirrors the
/// 2026-05-21 operator decision that no single card should pass 1000 lines
/// and surface the truncation honestly when it does.
///
/// loctree-fail hak 2026-05-23 #3: the Memory Trail card was emitting
/// 740 lines / 35 KB out of a ~50 KB atlas (87 % of total bytes). Without
/// a per-card cap, one fat slice silently pushed the other five cards
/// off the operator's screen budget. The cap below truncates the JSON
/// payload to `ATLAS_CARD_JSON_LINE_CAP` lines and appends a clear
/// `// truncated: N more lines, run \`loct context --full --json\` for
/// raw data` marker so the operator never reads a clipped card as
/// canonical truth.
pub(crate) const ATLAS_CARD_JSON_LINE_CAP: usize = 1000;

fn render_json_card(
    pack: &ContextPack,
    filename: &str,
    title: &str,
    lead: &str,
    missing: &str,
    value: serde_json::Value,
) -> CardBody {
    let full_json = serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string());
    let full_filename = full_json_filename(filename);
    let (json, dropped) = cap_json_payload(&full_json, ATLAS_CARD_JSON_LINE_CAP, &full_filename);
    let content = format!(
        "# {}\n\nProject: `{}`\nSnapshot: `{}`\nFreshness: `{}`\n\n{}\n\n```json\n{}\n```\n\n## What this card does not cover\n\n{}\n",
        title,
        pack.project.canonical_root.as_deref().unwrap_or("unknown"),
        snapshot_label(pack),
        atlas_freshness_line(pack),
        lead,
        json,
        missing
    );
    let overflow = dropped.map(|dropped| CardOverflow {
        full_payload_lines: line_count(&full_json),
        full_json,
        dropped,
        full_filename,
    });
    CardBody { content, overflow }
}

/// Truncate the JSON payload of an atlas card to `cap` lines, appending a
/// comment-marker tail that points at the concrete `full_filename` sibling
/// artifact when content was dropped. Returns the original payload unchanged
/// (and `None`) when it already fits; otherwise returns the capped payload and
/// `Some(dropped)` line count.
fn cap_json_payload(json: &str, cap: usize, full_filename: &str) -> (String, Option<usize>) {
    let line_total = json.lines().count();
    if line_total <= cap {
        return (json.to_string(), None);
    }
    let keep = cap.saturating_sub(1).max(1);
    let dropped = line_total - keep;
    let mut out = String::with_capacity(json.len());
    for (idx, line) in json.lines().enumerate() {
        if idx >= keep {
            break;
        }
        out.push_str(line);
        out.push('\n');
    }
    out.push_str(&format!(
        "// truncated: {dropped} more line(s); read the complete payload at `{full_filename}` (sibling of this card), or run `loct context --full --json` for the whole pack\n"
    ));
    (out, Some(dropped))
}

/// Map a card filename to its complete-payload sibling artifact:
/// `01-structural-map.md` -> `01-structural-map.full.json`.
fn full_json_filename(card_filename: &str) -> String {
    let stem = card_filename.strip_suffix(".md").unwrap_or(card_filename);
    format!("{stem}.full.json")
}

fn line_count(text: &str) -> usize {
    text.lines().count()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pack::{AuthorityLabel, ProjectIdentity, StructuralFile, StructuralRole};
    use tempfile::TempDir;

    #[test]
    fn materializes_manifest_and_named_cards_with_line_counts() {
        let tmp = TempDir::new().expect("temp dir");
        let atlas_dir = tmp.path().join("context-atlas");
        let mut pack = ContextPack::empty(ProjectIdentity {
            canonical_root: Some(tmp.path().display().to_string()),
            branch: Some("main".to_string()),
            commit: Some("abc1234".to_string()),
            snapshot_id: Some("scan-1".to_string()),
        });
        pack.action.next_safe_commands = vec!["cargo test --workspace".to_string()];

        let manifest = materialize_context_atlas(&pack, tmp.path(), Some(&atlas_dir))
            .expect("atlas should materialize");

        assert_eq!(manifest.protocol, CONTEXT_ATLAS_PROTOCOL);
        assert_eq!(manifest.cards.len(), 6);
        assert!(atlas_dir.join("manifest.md").exists());
        assert!(atlas_dir.join("manifest.json").exists());
        assert!(atlas_dir.join("00-core-map.md").exists());
        assert!(atlas_dir.join("05-risk-register.md").exists());

        for card in &manifest.cards {
            let content = fs::read_to_string(atlas_dir.join(&card.path)).expect("card content");
            assert_eq!(card.lines, content.lines().count());
            assert_eq!(card.bytes, content.len());
            assert!(content.contains("What this card does not cover"));
            assert!(
                content
                    .contains("Freshness: `fresh - card matches the loaded snapshot authority.`")
            );
            // An empty pack fits every card; nothing is capped and no sibling
            // `.full.json` artifact is written.
            assert!(!card.truncated, "{} unexpectedly truncated", card.path);
            assert_eq!(card.dropped_lines, 0);
            assert!(card.full_path.is_none());
            let sibling = atlas_dir.join(full_json_filename(&card.path));
            assert!(
                !sibling.exists(),
                "no sibling artifact expected for a whole card: {}",
                sibling.display()
            );
        }

        let manifest_md = fs::read_to_string(atlas_dir.join("manifest.md")).expect("manifest md");
        assert!(manifest_md.contains("Recommended Reading Path"));
        assert!(manifest_md.contains("Saves you from"));
        // No card overflowed, so the manifest carries no partial-card section.
        assert!(!manifest_md.contains("## Partial cards"));
    }

    /// loctree-fail hak 2026-05-23 #3 regression: a fat JSON card body
    /// must be truncated to the per-card line cap and append a clearly
    /// labelled tail so the operator never reads a clipped card as
    /// canonical truth.
    #[test]
    fn cap_json_payload_truncates_with_marker_pointing_at_sibling() {
        let big: String = (0..1500)
            .map(|i| format!("  \"row_{i}\": {i},"))
            .collect::<Vec<_>>()
            .join("\n");
        let (capped, dropped) = cap_json_payload(&big, 1000, "01-structural-map.full.json");
        let line_total = capped.lines().count();
        assert!(
            line_total <= 1000,
            "capped payload must fit cap, got {line_total} lines"
        );
        assert!(
            dropped.is_some_and(|d| d > 0),
            "over-cap input must report dropped line count"
        );
        let last = capped.lines().last().unwrap_or("");
        assert!(
            last.starts_with("// truncated:") && last.contains("more line(s)"),
            "tail marker must explain truncation explicitly, got: {last}"
        );
        assert!(
            last.contains("01-structural-map.full.json"),
            "marker must point at the concrete sibling artifact, got: {last}"
        );
    }

    #[test]
    fn cap_json_payload_keeps_small_input_unchanged() {
        let small = "{\n  \"a\": 1\n}".to_string();
        let (out, dropped) = cap_json_payload(&small, 1000, "00-core-map.full.json");
        assert_eq!(out, small);
        assert!(dropped.is_none(), "a fitting payload reports no truncation");
    }

    #[test]
    fn full_json_filename_swaps_md_for_full_json() {
        assert_eq!(
            full_json_filename("01-structural-map.md"),
            "01-structural-map.full.json"
        );
        assert_eq!(
            full_json_filename("00-core-map.md"),
            "00-core-map.full.json"
        );
    }

    /// loctree-fail tail 2026-06-22 regression: an over-cap card body must keep
    /// the complete payload around (for the sibling artifact) and embed an
    /// on-card marker pointing at that concrete file.
    #[test]
    fn render_json_card_overflow_carries_full_payload_and_marker() {
        let pack = ContextPack::empty(ProjectIdentity {
            canonical_root: Some("/tmp/proj".to_string()),
            branch: Some("main".to_string()),
            commit: Some("abc1234".to_string()),
            snapshot_id: None,
        });
        let rows: Vec<serde_json::Value> = (0..2000).map(|i| json!({ "row": i })).collect();
        let body = render_json_card(
            &pack,
            "01-structural-map.md",
            "Structural Map",
            "lead",
            "missing",
            json!(rows),
        );
        let overflow = body
            .overflow
            .expect("a 2000-entry payload must overflow the per-card cap");
        assert_eq!(overflow.full_filename, "01-structural-map.full.json");
        assert!(overflow.dropped > 0);
        assert!(
            overflow.full_payload_lines > ATLAS_CARD_JSON_LINE_CAP,
            "complete payload line count must preserve full sibling size"
        );
        assert!(
            body.content.contains("01-structural-map.full.json"),
            "card body must point readers at the complete sibling artifact"
        );
        assert!(body.content.contains("// truncated:"));
        // The complete payload is uncapped — the last row survives in the sibling.
        assert!(
            overflow.full_json.contains("\"row\": 1999"),
            "sibling artifact must hold the complete, uncapped payload"
        );
        assert!(
            overflow.full_json.lines().count() > ATLAS_CARD_JSON_LINE_CAP,
            "complete payload should exceed the per-card cap"
        );
    }

    #[test]
    fn render_json_card_surfaces_stale_snapshot_in_header() {
        let mut pack = ContextPack::empty(ProjectIdentity {
            canonical_root: Some("/tmp/proj".to_string()),
            branch: Some("main".to_string()),
            commit: Some("abc1234".to_string()),
            snapshot_id: None,
        });
        pack.risk.stale_snapshot = true;

        let body = render_json_card(
            &pack,
            "00-core-map.md",
            "Core Map",
            "lead",
            "missing",
            json!({}),
        );

        assert!(
            body.content
                .contains("Freshness: `STALE - card snapshot main@abc1234"),
            "stale atlas cards must carry a loud header flag: {}",
            body.content
        );
        assert!(
            body.content
                .contains("refresh with `loct context --full` before relying on this card")
        );
    }

    /// loctree-fail tail 2026-06-22 regression: the manifest must explicitly
    /// flag partial cards and name the concrete complete artifact, instead of
    /// silently quoting a line count for a clipped card.
    #[test]
    fn manifest_marks_partial_cards_and_points_to_full_artifact() {
        let manifest = ContextAtlasManifest {
            protocol: CONTEXT_ATLAS_PROTOCOL.to_string(),
            status: "atlas_ready".to_string(),
            project: "proj".to_string(),
            snapshot: "main@abc1234".to_string(),
            generated_at: "2026-06-22T00:00:00Z".to_string(),
            atlas_dir: "/tmp/proj/.loctree/context-atlas".to_string(),
            manifest: "manifest.md".to_string(),
            manifest_json: "manifest.json".to_string(),
            recommended_start: "00-core-map.md".to_string(),
            cards: vec![ContextAtlasCard {
                id: "structural".to_string(),
                title: "Structural Map".to_string(),
                path: "01-structural-map.md".to_string(),
                lines: 1014,
                bytes: 40_000,
                why: "why".to_string(),
                saves_you_from: "saves".to_string(),
                truncated: true,
                dropped_lines: 2589,
                full_path: Some("01-structural-map.full.json".to_string()),
                full_payload_lines: Some(3602),
            }],
            message: "msg".to_string(),
        };
        let md = render_manifest(&manifest);
        assert!(
            md.contains("## Partial cards"),
            "manifest must call out partial cards"
        );
        assert!(
            md.contains("01-structural-map.full.json"),
            "manifest must point to the concrete complete artifact"
        );
        assert!(
            md.contains("1014 materialized lines / 3602 full-payload lines"),
            "manifest must quote materialized and full-payload lengths"
        );
        assert!(
            md.contains("Partial-card completeness is stricter"),
            "completeness footer must make partial cards part of read-to-end"
        );
        assert!(
            md.contains("2589"),
            "manifest should quote the dropped-line magnitude"
        );
    }

    #[test]
    fn cli_summary_reports_partial_cards_with_materialized_and_full_payload_lines() {
        let manifest = ContextAtlasManifest {
            protocol: CONTEXT_ATLAS_PROTOCOL.to_string(),
            status: "atlas_ready".to_string(),
            project: "proj".to_string(),
            snapshot: "main@abc1234".to_string(),
            generated_at: "2026-07-01T00:00:00Z".to_string(),
            atlas_dir: "/tmp/proj/.loctree/context-atlas".to_string(),
            manifest: "manifest.md".to_string(),
            manifest_json: "manifest.json".to_string(),
            recommended_start: "00-core-map.md".to_string(),
            cards: vec![ContextAtlasCard {
                id: "structural".to_string(),
                title: "Structural Map".to_string(),
                path: "01-structural-map.md".to_string(),
                lines: 1008,
                bytes: 40_000,
                why: "why".to_string(),
                saves_you_from: "saves".to_string(),
                truncated: true,
                dropped_lines: 3870,
                full_path: Some("01-structural-map.full.json".to_string()),
                full_payload_lines: Some(4869),
            }],
            message: "msg".to_string(),
        };

        let summary = manifest.render_cli_summary();
        assert!(
            summary.contains("1008 materialized lines / 4869 full-payload lines"),
            "CLI summary must not blur materialized card length with full payload: {summary}"
        );
        assert!(
            summary.contains("read complete payload at 01-structural-map.full.json"),
            "CLI summary must send readers to the concrete sibling artifact: {summary}"
        );
    }

    #[test]
    fn materialized_atlas_records_partial_card_full_payload_truth() {
        let tmp = TempDir::new().expect("temp dir");
        let atlas_dir = tmp.path().join("context-atlas");
        let mut pack = ContextPack::empty(ProjectIdentity {
            canonical_root: Some(tmp.path().display().to_string()),
            branch: Some("main".to_string()),
            commit: Some("abc1234".to_string()),
            snapshot_id: Some("scan-1".to_string()),
        });
        pack.structural.files = (0..1200)
            .map(|idx| StructuralFile {
                path: format!("src/generated_{idx}.rs"),
                role: StructuralRole::Dependency,
                depth: 1,
                language: "rs".to_string(),
                loc: idx,
                authority: AuthorityLabel::RepoVerified,
            })
            .collect();

        let manifest = materialize_context_atlas(&pack, tmp.path(), Some(&atlas_dir))
            .expect("atlas should materialize");
        let card = manifest
            .cards
            .iter()
            .find(|card| card.path == "01-structural-map.md")
            .expect("structural card");
        assert!(card.truncated, "oversized structural card must be partial");

        let card_content =
            fs::read_to_string(atlas_dir.join(&card.path)).expect("structural card content");
        assert_eq!(
            card.lines,
            card_content.lines().count(),
            "manifest must quote the materialized .md card length"
        );
        assert!(
            card_content.contains("// truncated:"),
            "materialized card must carry an in-card truncation marker"
        );

        let full_path = card.full_path.as_deref().expect("full sibling path");
        let full_payload =
            fs::read_to_string(atlas_dir.join(full_path)).expect("full sibling payload");
        assert!(
            full_payload.ends_with('\n'),
            "full sibling must be newline-terminated so `wc -l` matches manifest truth"
        );
        assert_eq!(
            card.full_payload_lines,
            Some(full_payload.lines().count()),
            "manifest JSON must quote the full sibling payload length"
        );

        let expected = format!(
            "{} materialized lines / {} full-payload lines",
            card.lines,
            card.full_payload_lines.expect("full payload lines")
        );
        let manifest_md = fs::read_to_string(atlas_dir.join("manifest.md")).expect("manifest md");
        assert!(
            manifest_md.contains(&expected),
            "manifest markdown must make the materialized/full split explicit: {manifest_md}"
        );
        assert!(
            manifest.render_cli_summary().contains(&expected),
            "CLI summary must make the materialized/full split explicit"
        );
    }
}