1use 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
18pub const LESSONS_INJECT_MAX_BYTES: usize = 2048;
20
21const MAX_INDEX_ENTRIES: usize = 10;
23
24const 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
31pub(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
91pub(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
188pub 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 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 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 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 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
307fn 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
334fn 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 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 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 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 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 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}