Skip to main content

kranz_engine/
lessons.rs

1//! Safe storage and rendering of repo-level lessons.
2//!
3//! Lesson paths live in a worker-writable mission tree but are read and written
4//! by the trusted engine, so every filesystem operation must stay beneath the
5//! canonical repo-owned `.kranz/lessons` directory and reject symlinks. The
6//! renderer reads the append-only manifest (capture order, oldest first) and
7//! emits a byte-capped planning-seed block.
8
9use crate::error::{EngineError, Result};
10use crate::paths::MissionPaths;
11use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
12use cap_std::ambient_authority;
13use cap_std::fs::{Dir, OpenOptions};
14use std::io::{ErrorKind, Read as _, Write as _};
15use std::path::Path;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18/// Hard cap (bytes) on the rendered lessons-index string.
19pub const LESSONS_INJECT_MAX_BYTES: usize = 2048;
20
21/// At most this many of the most recent lessons appear as manifest entries.
22const MAX_INDEX_ENTRIES: usize = 10;
23
24/// Of those, at most this many (the newest) get their full body inlined.
25const MAX_FULL_BODIES: usize = 3;
26
27const HEADER: &str = "## Lessons from past missions in this repo\n\n";
28
29static LESSON_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
30
31/// Persist one normalized lesson and append its manifest entry without ever
32/// following a worker-authored symlink outside the active repository.
33pub(crate) fn write_lesson(
34    repo_root: &Path,
35    mission_id: &str,
36    body: &str,
37) -> Result<Vec<std::path::PathBuf>> {
38    if !MissionPaths::is_safe_id(mission_id) {
39        return Err(EngineError::InvalidState(format!(
40            "unsafe mission id for lesson capture: {mission_id}"
41        )));
42    }
43    let lessons = open_lessons_dir(repo_root, true)?;
44    write_lesson_in_dir(&lessons, mission_id, body)?;
45
46    let lessons_path = repo_root.join(".kranz").join("lessons");
47    Ok(vec![
48        lessons_path.join(format!("{mission_id}.md")),
49        lessons_path.join("index.md"),
50    ])
51}
52
53fn write_lesson_in_dir(lessons: &Dir, mission_id: &str, body: &str) -> Result<()> {
54    let lesson_name = format!("{mission_id}.md");
55    ensure_absent_or_regular(lessons, &lesson_name)?;
56    let mut manifest = read_existing_regular(lessons, "index.md")?.unwrap_or_default();
57    let summary = first_nonempty_line(body).unwrap_or_default();
58    manifest.push_str(&format!("- {mission_id}.md · {summary}\n"));
59
60    atomic_replace(lessons, &lesson_name, body.as_bytes())?;
61    atomic_replace(lessons, "index.md", manifest.as_bytes())
62}
63
64fn open_lessons_dir(repo_root: &Path, create: bool) -> Result<Dir> {
65    let repo = Dir::open_ambient_dir(repo_root, ambient_authority())?;
66    let kranz_metadata = repo.symlink_metadata(".kranz")?;
67    if !kranz_metadata.file_type().is_dir() {
68        return Err(unsafe_lessons_path(&repo_root.join(".kranz")));
69    }
70    let kranz = repo
71        .open_dir_nofollow(".kranz")
72        .map_err(|_| unsafe_lessons_path(&repo_root.join(".kranz")))?;
73
74    match kranz.symlink_metadata("lessons") {
75        Ok(metadata) if metadata.file_type().is_dir() => {}
76        Ok(_) => return Err(unsafe_lessons_path(&repo_root.join(".kranz/lessons"))),
77        Err(error) if error.kind() == ErrorKind::NotFound && create => {
78            match kranz.create_dir("lessons") {
79                Ok(()) => {}
80                Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
81                Err(error) => return Err(error.into()),
82            }
83        }
84        Err(error) => return Err(error.into()),
85    }
86    kranz
87        .open_dir_nofollow("lessons")
88        .map_err(|_| unsafe_lessons_path(&repo_root.join(".kranz/lessons")))
89}
90
91/// The working-tree text of one lesson file, read through the same
92/// no-follow discipline the renderer uses, or `None` when it is absent or
93/// unreadable.
94///
95/// Exists so the provenance check ([`crate::judgement::lesson_provenance_clean`])
96/// can compare the bytes the renderer WILL read against the blob in the
97/// commit it verified. Provenance answers "which commit added this path";
98/// only this comparison answers "are these the bytes that commit carried"
99/// (audit H7).
100pub(crate) fn read_lesson_from_worktree(repo_root: &Path, filename: &str) -> Option<String> {
101    if filename.is_empty()
102        || filename.contains(['/', '\\'])
103        || Path::new(filename).components().count() != 1
104    {
105        return None;
106    }
107    let lessons = open_lessons_dir(repo_root, false).ok()?;
108    read_existing_regular(&lessons, filename).ok().flatten()
109}
110
111fn ensure_absent_or_regular(dir: &Dir, name: &str) -> Result<()> {
112    match dir.symlink_metadata(name) {
113        Ok(metadata) if metadata.file_type().is_file() => Ok(()),
114        Ok(_) => Err(unsafe_lessons_path(Path::new(name))),
115        Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
116        Err(error) => Err(error.into()),
117    }
118}
119
120fn read_existing_regular(dir: &Dir, name: &str) -> Result<Option<String>> {
121    match dir.symlink_metadata(name) {
122        Ok(metadata) if metadata.file_type().is_file() => {}
123        Ok(_) => return Err(unsafe_lessons_path(Path::new(name))),
124        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
125        Err(error) => return Err(error.into()),
126    }
127    let mut options = OpenOptions::new();
128    options.read(true).follow(FollowSymlinks::No);
129    match dir.open_with(name, &options) {
130        Ok(mut file) => {
131            if !file.metadata()?.is_file() {
132                return Err(unsafe_lessons_path(Path::new(name)));
133            }
134            let mut text = String::new();
135            file.read_to_string(&mut text)?;
136            Ok(Some(text))
137        }
138        Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
139        Err(error) => Err(error.into()),
140    }
141}
142
143fn atomic_replace(dir: &Dir, name: &str, bytes: &[u8]) -> Result<()> {
144    let tmp = format!(
145        ".{name}.{}.{}.tmp",
146        std::process::id(),
147        LESSON_TMP_SEQ.fetch_add(1, Ordering::Relaxed)
148    );
149    let result = (|| -> Result<()> {
150        let mut options = OpenOptions::new();
151        options
152            .write(true)
153            .create_new(true)
154            .follow(FollowSymlinks::No);
155        let mut file = dir.open_with(&tmp, &options)?;
156        file.write_all(bytes)?;
157        drop(file);
158        match dir.rename(&tmp, dir, name) {
159            Ok(()) => Ok(()),
160            #[cfg(windows)]
161            Err(_) => {
162                ensure_absent_or_regular(dir, name)?;
163                match dir.symlink_metadata(name) {
164                    Ok(_) => dir.remove_file_or_symlink(name)?,
165                    Err(error) if error.kind() == ErrorKind::NotFound => {}
166                    Err(error) => return Err(error.into()),
167                }
168                dir.rename(&tmp, dir, name)?;
169                Ok(())
170            }
171            #[cfg(not(windows))]
172            Err(error) => Err(error.into()),
173        }
174    })();
175    if result.is_err() {
176        let _ = dir.remove_file(&tmp);
177    }
178    result
179}
180
181fn unsafe_lessons_path(path: &Path) -> EngineError {
182    EngineError::InvalidState(format!(
183        "refusing lesson path outside the repo-owned regular-file tree: {}",
184        path.display()
185    ))
186}
187
188/// Render the byte-capped recent-lessons block for a planning seed: a
189/// manifest (id + one-line summary) of the most recent provenance-clean
190/// lessons, plus the full body of the newest [`MAX_FULL_BODIES`] of them.
191/// `None` when no provenance-clean lesson survives.
192///
193/// `is_provenance_clean` is called with each lesson's filename (`<id>.md`);
194/// the caller supplies the git-history check (was this file added by a
195/// `[kranz] mission report` commit for that mission?) so this module stays
196/// filesystem-only and unit-testable. That filter is what stops a
197/// worker-dropped or otherwise arbitrary file in `.kranz/lessons/` from
198/// injecting text into a future planner. Bodies are recency-selected (a
199/// mission has no touch_set at planning time to rank relevance against).
200pub fn render_lessons_manifest(
201    repo_root: &Path,
202    is_provenance_clean: &dyn Fn(&str) -> bool,
203) -> Option<String> {
204    let lessons = open_lessons_dir(repo_root, false).ok()?;
205    render_lessons_manifest_in_dir(&lessons, is_provenance_clean)
206}
207
208fn render_lessons_manifest_in_dir(
209    lessons: &Dir,
210    is_provenance_clean: &dyn Fn(&str) -> bool,
211) -> Option<String> {
212    let manifest = read_existing_regular(lessons, "index.md").ok()??;
213
214    let lines: Vec<&str> = manifest
215        .lines()
216        .map(str::trim)
217        .filter(|l| !l.is_empty())
218        .collect();
219    if lines.is_empty() {
220        return None;
221    }
222
223    struct Entry {
224        filename: String,
225        first_line: String,
226        body: Option<String>,
227    }
228
229    // Manifest is append-only, oldest first; collect the most recent
230    // provenance-clean lessons, newest first, up to the index cap. The body
231    // is read for the newest few (recency-selected, since a mission has no
232    // touch_set at planning time to rank relevance against).
233    let mut entries: Vec<Entry> = Vec::new();
234    for line in lines.iter().rev() {
235        if entries.len() >= MAX_INDEX_ENTRIES {
236            break;
237        }
238        let Some((filename, summary)) = parse_manifest_line(line) else {
239            continue;
240        };
241        if !is_provenance_clean(&filename) {
242            continue;
243        }
244        let file_text = read_existing_regular(lessons, &filename).ok().flatten();
245        // Prefer the actual (provenance-checked) file's first line over the
246        // manifest summary, which a worker could have rewritten.
247        let first_line = file_text
248            .as_deref()
249            .and_then(first_nonempty_line)
250            .map(str::to_string)
251            .unwrap_or(summary);
252        let body = if entries.len() < MAX_FULL_BODIES {
253            file_text
254        } else {
255            None
256        };
257        entries.push(Entry {
258            filename,
259            first_line,
260            body,
261        });
262    }
263    if entries.is_empty() {
264        return None;
265    }
266
267    let mut out = String::with_capacity(LESSONS_INJECT_MAX_BYTES);
268    out.push_str(HEADER);
269
270    // Manifest lines are highest priority: newest-first, stopping (and
271    // truncating the last) the moment the cap would be exceeded.
272    for entry in &entries {
273        let remaining = LESSONS_INJECT_MAX_BYTES.saturating_sub(out.len());
274        if remaining == 0 {
275            break;
276        }
277        let line = format!("- {} — {}\n", entry.filename, entry.first_line);
278        if line.len() <= remaining {
279            out.push_str(&line);
280        } else {
281            out.push_str(&truncate_to_bytes(&line, remaining));
282            break;
283        }
284    }
285
286    // Full bodies for the newest few clean lessons: lower priority than the
287    // manifest, so they are dropped/truncated first when space is tight.
288    for entry in entries.iter().filter(|e| e.body.is_some()) {
289        let remaining = LESSONS_INJECT_MAX_BYTES.saturating_sub(out.len());
290        if remaining == 0 {
291            break;
292        }
293        let body = entry.body.as_deref().unwrap_or_default();
294        let chunk = format!("\n### {}\n{}\n", entry.filename, body.trim_end());
295        if chunk.len() <= remaining {
296            out.push_str(&chunk);
297        } else {
298            out.push_str(&truncate_to_bytes(&chunk, remaining));
299            break;
300        }
301    }
302
303    debug_assert!(out.len() <= LESSONS_INJECT_MAX_BYTES);
304    Some(out)
305}
306
307/// Split a manifest line of the form `- <filename> · <summary>` into its
308/// filename and summary parts. Only a single `.md` basename is accepted:
309/// manifest contents are repository-controlled and must never escape the
310/// lessons directory when joined.
311fn parse_manifest_line(line: &str) -> Option<(String, String)> {
312    let line = line.trim_start_matches('-').trim();
313    let (filename, summary) = match line.split_once('·') {
314        Some((filename, summary)) => (filename.trim(), summary.trim()),
315        None => (line, ""),
316    };
317    if filename.is_empty()
318        || !filename.ends_with(".md")
319        || filename.contains(['/', '\\'])
320        || Path::new(filename)
321            .file_name()
322            .and_then(|name| name.to_str())
323            != Some(filename)
324    {
325        return None;
326    }
327    Some((filename.to_string(), summary.to_string()))
328}
329
330fn first_nonempty_line(text: &str) -> Option<&str> {
331    text.lines().map(str::trim).find(|l| !l.is_empty())
332}
333
334/// Truncate `s` to at most `limit` bytes, cutting on a UTF-8 char boundary.
335fn truncate_to_bytes(s: &str, limit: usize) -> String {
336    if s.len() <= limit {
337        return s.to_string();
338    }
339    let mut end = limit;
340    while end > 0 && !s.is_char_boundary(end) {
341        end -= 1;
342    }
343    s[..end].to_string()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn write_lesson(dir: &Path, id: &str, first_line: &str, rest: &str) {
351        let lessons_dir = dir.join(".kranz").join("lessons");
352        std::fs::create_dir_all(&lessons_dir).unwrap();
353        let body = if rest.is_empty() {
354            format!("{first_line}\n")
355        } else {
356            format!("{first_line}\n{rest}\n")
357        };
358        std::fs::write(lessons_dir.join(format!("{id}.md")), body).unwrap();
359        let index = lessons_dir.join("index.md");
360        let line = format!("- {id}.md · {first_line}\n");
361        use std::io::Write as _;
362        let mut f = std::fs::OpenOptions::new()
363            .create(true)
364            .append(true)
365            .open(index)
366            .unwrap();
367        f.write_all(line.as_bytes()).unwrap();
368    }
369
370    #[test]
371    fn returns_none_when_index_missing() {
372        let dir = tempfile::tempdir().unwrap();
373        assert!(render_lessons_manifest(dir.path(), &|_: &str| true).is_none());
374    }
375
376    #[test]
377    fn returns_none_when_index_empty() {
378        let dir = tempfile::tempdir().unwrap();
379        let lessons_dir = dir.path().join(".kranz").join("lessons");
380        std::fs::create_dir_all(&lessons_dir).unwrap();
381        std::fs::write(lessons_dir.join("index.md"), "").unwrap();
382        assert!(render_lessons_manifest(dir.path(), &|_: &str| true).is_none());
383    }
384
385    #[test]
386    fn ignores_manifest_paths_outside_the_lessons_directory() {
387        let dir = tempfile::tempdir().unwrap();
388        let lessons_dir = dir.path().join(".kranz").join("lessons");
389        std::fs::create_dir_all(&lessons_dir).unwrap();
390        let outside = dir.path().join("outside.md");
391        std::fs::write(&outside, "LOCAL SECRET\n").unwrap();
392        std::fs::write(lessons_dir.join("safe.md"), "SAFE LESSON\n").unwrap();
393        std::fs::write(
394            lessons_dir.join("index.md"),
395            format!(
396                "- ../../outside.md · traversal\n- {} · absolute\n- safe.md · safe\n",
397                outside.display()
398            ),
399        )
400        .unwrap();
401
402        let rendered =
403            render_lessons_manifest(dir.path(), &|_: &str| true).expect("safe lesson remains");
404        assert!(rendered.contains("SAFE LESSON"), "{rendered}");
405        assert!(!rendered.contains("LOCAL SECRET"), "{rendered}");
406        assert!(!rendered.contains("../../outside.md"), "{rendered}");
407        assert!(
408            !rendered.contains(&outside.display().to_string()),
409            "{rendered}"
410        );
411    }
412
413    #[cfg(unix)]
414    #[test]
415    fn ignores_symlinked_lesson_files() {
416        use std::os::unix::fs::symlink;
417
418        let dir = tempfile::tempdir().unwrap();
419        let lessons_dir = dir.path().join(".kranz").join("lessons");
420        std::fs::create_dir_all(&lessons_dir).unwrap();
421        let outside = dir.path().join("outside.md");
422        std::fs::write(&outside, "LOCAL SECRET\n").unwrap();
423        symlink(&outside, lessons_dir.join("linked.md")).unwrap();
424        std::fs::write(lessons_dir.join("index.md"), "- linked.md · fallback\n").unwrap();
425
426        let rendered =
427            render_lessons_manifest(dir.path(), &|_: &str| true).expect("summary remains safe");
428        assert!(rendered.contains("fallback"), "{rendered}");
429        assert!(!rendered.contains("LOCAL SECRET"), "{rendered}");
430    }
431
432    #[cfg(unix)]
433    #[test]
434    fn ignores_a_symlinked_manifest() {
435        use std::os::unix::fs::symlink;
436
437        let dir = tempfile::tempdir().unwrap();
438        let lessons_dir = dir.path().join(".kranz").join("lessons");
439        std::fs::create_dir_all(&lessons_dir).unwrap();
440        let outside = dir.path().join("outside-index.md");
441        std::fs::write(&outside, "- safe.md · EXTERNAL MANIFEST TEXT\n").unwrap();
442        std::fs::write(lessons_dir.join("safe.md"), "SAFE LESSON\n").unwrap();
443        symlink(&outside, lessons_dir.join("index.md")).unwrap();
444
445        assert!(render_lessons_manifest(dir.path(), &|_: &str| true).is_none());
446    }
447
448    #[cfg(unix)]
449    #[test]
450    fn ignores_a_symlinked_lessons_directory() {
451        use std::os::unix::fs::symlink;
452
453        let dir = tempfile::tempdir().unwrap();
454        let outside = tempfile::tempdir().unwrap();
455        std::fs::write(outside.path().join("index.md"), "- linked.md · fallback\n").unwrap();
456        std::fs::write(outside.path().join("linked.md"), "LOCAL SECRET\n").unwrap();
457        std::fs::create_dir_all(dir.path().join(".kranz")).unwrap();
458        symlink(outside.path(), dir.path().join(".kranz/lessons")).unwrap();
459
460        assert!(render_lessons_manifest(dir.path(), &|_: &str| true).is_none());
461    }
462
463    #[test]
464    fn lesson_write_replaces_the_existing_manifest_without_temp_residue() {
465        let dir = tempfile::tempdir().unwrap();
466        std::fs::create_dir(dir.path().join(".kranz")).unwrap();
467
468        super::write_lesson(dir.path(), "m-one", "FIRST LESSON\n").unwrap();
469        super::write_lesson(dir.path(), "m-two", "SECOND LESSON\n").unwrap();
470
471        let lessons_dir = dir.path().join(".kranz/lessons");
472        assert_eq!(
473            std::fs::read_to_string(lessons_dir.join("index.md")).unwrap(),
474            "- m-one.md · FIRST LESSON\n- m-two.md · SECOND LESSON\n"
475        );
476        assert_eq!(
477            std::fs::read_to_string(lessons_dir.join("m-two.md")).unwrap(),
478            "SECOND LESSON\n"
479        );
480        assert!(
481            std::fs::read_dir(lessons_dir).unwrap().all(|entry| !entry
482                .unwrap()
483                .file_name()
484                .to_string_lossy()
485                .ends_with(".tmp")),
486            "atomic replacement must clean temporary files"
487        );
488    }
489
490    #[cfg(unix)]
491    #[test]
492    fn lesson_write_refuses_symlinked_destinations_without_touching_targets() {
493        use std::os::unix::fs::symlink;
494
495        for destination in ["m-safe.md", "index.md"] {
496            let dir = tempfile::tempdir().unwrap();
497            let lessons_dir = dir.path().join(".kranz").join("lessons");
498            std::fs::create_dir_all(&lessons_dir).unwrap();
499            let outside = dir.path().join("outside.md");
500            std::fs::write(&outside, "DO NOT CHANGE\n").unwrap();
501            symlink(&outside, lessons_dir.join(destination)).unwrap();
502
503            let error = super::write_lesson(dir.path(), "m-safe", "SAFE LESSON").unwrap_err();
504
505            assert!(
506                error.to_string().contains("refusing lesson path"),
507                "{error}"
508            );
509            assert_eq!(std::fs::read_to_string(outside).unwrap(), "DO NOT CHANGE\n");
510        }
511    }
512
513    #[cfg(unix)]
514    #[test]
515    fn lesson_write_refuses_a_symlinked_lessons_directory() {
516        use std::os::unix::fs::symlink;
517
518        let dir = tempfile::tempdir().unwrap();
519        let outside = tempfile::tempdir().unwrap();
520        std::fs::create_dir_all(dir.path().join(".kranz")).unwrap();
521        symlink(outside.path(), dir.path().join(".kranz/lessons")).unwrap();
522
523        let error = super::write_lesson(dir.path(), "m-safe", "SAFE LESSON").unwrap_err();
524
525        assert!(
526            error.to_string().contains("refusing lesson path"),
527            "{error}"
528        );
529        assert!(!outside.path().join("m-safe.md").exists());
530        assert!(!outside.path().join("index.md").exists());
531    }
532
533    #[cfg(unix)]
534    #[test]
535    fn lesson_write_stays_bound_to_the_open_directory_after_path_swap() {
536        use std::os::unix::fs::symlink;
537
538        let dir = tempfile::tempdir().unwrap();
539        let lessons_path = dir.path().join(".kranz/lessons");
540        std::fs::create_dir_all(&lessons_path).unwrap();
541        let lessons = open_lessons_dir(dir.path(), false).unwrap();
542        let held_path = dir.path().join(".kranz/lessons-held");
543        std::fs::rename(&lessons_path, &held_path).unwrap();
544        let outside = tempfile::tempdir().unwrap();
545        symlink(outside.path(), &lessons_path).unwrap();
546
547        write_lesson_in_dir(&lessons, "m-safe", "SAFE LESSON").unwrap();
548
549        assert_eq!(
550            std::fs::read_to_string(held_path.join("m-safe.md")).unwrap(),
551            "SAFE LESSON"
552        );
553        assert!(held_path.join("index.md").is_file());
554        assert!(!outside.path().join("m-safe.md").exists());
555        assert!(!outside.path().join("index.md").exists());
556    }
557
558    #[cfg(unix)]
559    #[test]
560    fn lesson_render_stays_bound_to_the_open_directory_after_path_swap() {
561        use std::os::unix::fs::symlink;
562
563        let dir = tempfile::tempdir().unwrap();
564        let lessons_path = dir.path().join(".kranz/lessons");
565        std::fs::create_dir_all(&lessons_path).unwrap();
566        std::fs::write(lessons_path.join("index.md"), "- safe.md · SAFE SUMMARY\n").unwrap();
567        std::fs::write(lessons_path.join("safe.md"), "SAFE LESSON\n").unwrap();
568        let lessons = open_lessons_dir(dir.path(), false).unwrap();
569        let held_path = dir.path().join(".kranz/lessons-held");
570        std::fs::rename(&lessons_path, &held_path).unwrap();
571        let outside = tempfile::tempdir().unwrap();
572        std::fs::write(
573            outside.path().join("index.md"),
574            "- evil.md · EXTERNAL SUMMARY\n",
575        )
576        .unwrap();
577        std::fs::write(outside.path().join("evil.md"), "LOCAL SECRET\n").unwrap();
578        symlink(outside.path(), &lessons_path).unwrap();
579
580        let rendered = render_lessons_manifest_in_dir(&lessons, &|_: &str| true).unwrap();
581
582        assert!(rendered.contains("SAFE LESSON"), "{rendered}");
583        assert!(!rendered.contains("LOCAL SECRET"), "{rendered}");
584        assert!(!rendered.contains("EXTERNAL SUMMARY"), "{rendered}");
585    }
586
587    #[test]
588    fn lists_up_to_ten_newest_first_with_three_clean_bodies() {
589        let dir = tempfile::tempdir().unwrap();
590        for i in 1..=13 {
591            write_lesson(
592                dir.path(),
593                &format!("m{i:02}"),
594                &format!("SUMMARY-{i:02}-END"),
595                &format!("DETAIL-{i:02}-END"),
596            );
597        }
598
599        let rendered =
600            render_lessons_manifest(dir.path(), &|_: &str| true).expect("lessons present");
601        assert!(rendered.starts_with("## Lessons from past missions in this repo"));
602
603        // Newest-first, at most the 10 most recent as manifest entries.
604        let pos_m13 = rendered.find("m13.md").expect("m13 listed");
605        let pos_m12 = rendered.find("m12.md").expect("m12 listed");
606        assert!(pos_m13 < pos_m12, "newest lesson must appear first");
607        assert!(
608            !rendered.contains("m03.md"),
609            "only the 10 most recent listed"
610        );
611        assert!(
612            rendered.contains("m04.md"),
613            "the 10th most recent still listed"
614        );
615        for i in 1..=13 {
616            assert_eq!(
617                rendered.contains(&format!("SUMMARY-{i:02}-END")),
618                i >= 4,
619                "manifest summary present only for the 10 most recent (mission {i})"
620            );
621        }
622
623        // Full bodies only for the 3 newest (recency-selected), never beyond.
624        for i in [13, 12, 11] {
625            assert!(
626                rendered.contains(&format!("DETAIL-{i:02}-END")),
627                "full body expected for the newest lessons (mission {i})"
628            );
629        }
630        for i in [10, 9, 4] {
631            assert!(
632                !rendered.contains(&format!("DETAIL-{i:02}-END")),
633                "no body beyond the 3 newest (mission {i})"
634            );
635        }
636    }
637
638    #[test]
639    fn body_selection_skips_lessons_the_provenance_predicate_rejects() {
640        let dir = tempfile::tempdir().unwrap();
641        for i in 1..=5 {
642            write_lesson(
643                dir.path(),
644                &format!("m{i:02}"),
645                &format!("SUMMARY-{i:02}"),
646                &format!("DETAIL-{i:02}-END"),
647            );
648        }
649        // Reject the two newest — their bodies must not slip in, and the body
650        // budget applies to the CLEAN lessons only (m03, m02, m01).
651        let rejected = ["m05.md", "m04.md"];
652        let rendered = render_lessons_manifest(dir.path(), &|f: &str| !rejected.contains(&f))
653            .expect("clean lessons remain");
654
655        assert!(!rendered.contains("m05.md") && !rendered.contains("DETAIL-05-END"));
656        assert!(!rendered.contains("m04.md") && !rendered.contains("DETAIL-04-END"));
657        for i in [3, 2, 1] {
658            assert!(
659                rendered.contains(&format!("DETAIL-{i:02}-END")),
660                "clean lesson body expected (mission {i})"
661            );
662        }
663    }
664
665    #[test]
666    fn provenance_predicate_filters_out_unclean_lessons() {
667        let dir = tempfile::tempdir().unwrap();
668        write_lesson(dir.path(), "m-clean", "CLEAN SUMMARY", "clean detail");
669        write_lesson(dir.path(), "m-forged", "FORGED SUMMARY", "forged detail");
670
671        // Predicate accepts only the clean lesson (as the git-history check
672        // would for a genuine `[kranz] mission report` commit).
673        let rendered =
674            render_lessons_manifest(dir.path(), &|filename: &str| filename == "m-clean.md")
675                .expect("the clean lesson survives");
676
677        assert!(rendered.contains("m-clean.md"), "{rendered}");
678        assert!(rendered.contains("CLEAN SUMMARY"), "{rendered}");
679        assert!(
680            !rendered.contains("m-forged.md") && !rendered.contains("FORGED SUMMARY"),
681            "a lesson the predicate rejects must never reach the prompt: {rendered}"
682        );
683
684        // When the predicate rejects everything, nothing is injected (not even
685        // a bare header).
686        assert!(render_lessons_manifest(dir.path(), &|_: &str| false).is_none());
687    }
688
689    #[test]
690    fn hard_byte_cap_holds_with_many_huge_first_lines() {
691        let dir = tempfile::tempdir().unwrap();
692        let huge_line = "y".repeat(5_000);
693        for i in 1..=15 {
694            write_lesson(dir.path(), &format!("m{i:02}"), &huge_line, "");
695        }
696
697        let rendered =
698            render_lessons_manifest(dir.path(), &|_: &str| true).expect("lessons present");
699        assert!(rendered.len() <= LESSONS_INJECT_MAX_BYTES);
700    }
701
702    #[test]
703    fn render_performs_no_filesystem_writes() {
704        let dir = tempfile::tempdir().unwrap();
705        for i in 1..=5 {
706            write_lesson(
707                dir.path(),
708                &format!("m{i:02}"),
709                &format!("summary {i}"),
710                "detail",
711            );
712        }
713        let lessons_dir = dir.path().join(".kranz").join("lessons");
714
715        let before: Vec<_> = std::fs::read_dir(&lessons_dir)
716            .unwrap()
717            .map(|e| e.unwrap().file_name())
718            .collect();
719        let index_before = std::fs::read_to_string(lessons_dir.join("index.md")).unwrap();
720
721        let _ = render_lessons_manifest(dir.path(), &|_: &str| true);
722
723        let mut after: Vec<_> = std::fs::read_dir(&lessons_dir)
724            .unwrap()
725            .map(|e| e.unwrap().file_name())
726            .collect();
727        let mut before_sorted = before.clone();
728        before_sorted.sort();
729        after.sort();
730        assert_eq!(before_sorted, after, "no files created or removed");
731        let index_after = std::fs::read_to_string(lessons_dir.join("index.md")).unwrap();
732        assert_eq!(index_before, index_after, "manifest must not be modified");
733    }
734}