use crate::config::{Adapter, Config};
use crate::paths::Paths;
use crate::store::{self, StoreDoc};
use anyhow::Result;
use std::path::PathBuf;
pub struct Section {
pub title: String,
pub source: String,
pub body: String,
}
impl Section {
fn lines(&self) -> usize { self.body.lines().count() }
}
fn collect(paths: &Paths, cfg: &Config, adapter: &Adapter) -> Result<Vec<Section>> {
let mut out = Vec::new();
for id in &adapter.sections {
let section = match id.as_str() {
"product" => doc_section(paths, id, "What this is", paths.product())?,
"tech" => doc_section(paths, id, "Stack and constraints", paths.tech())?,
"conventions" => conventions_section(paths, cfg)?,
"structure" => doc_section(paths, id, "Repository map", paths.structure())?,
"lessons" => lessons_section(paths, cfg)?,
_ => None,
};
if let Some(s) = section
&& !s.body.trim().is_empty()
{
out.push(s);
}
}
Ok(out)
}
fn doc_section(paths: &Paths, id: &str, title: &str, path: PathBuf) -> Result<Option<Section>> {
let Some(doc) = StoreDoc::read_optional(&path)? else { return Ok(None) };
let _ = id;
Ok(Some(Section {
title: title.to_string(),
source: paths.rel(&path).to_string_lossy().replace('\\', "/"),
body: demote_headings(&strip_generated_notice(doc.body_without_title())).trim().to_string(),
}))
}
fn demote_headings(body: &str) -> String {
let mut out = Vec::with_capacity(body.lines().count());
let mut in_fence = false;
for line in body.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
out.push(line.to_string());
continue;
}
let hashes = line.chars().take_while(|c| *c == '#').count();
let is_heading = !in_fence
&& hashes > 0
&& hashes < 6
&& line.chars().nth(hashes) == Some(' ');
if is_heading {
out.push(format!("#{line}"));
} else {
out.push(line.to_string());
}
}
out.join("\n")
}
fn strip_generated_notice(body: &str) -> String {
body.lines()
.filter(|l| !l.trim_start().starts_with("<!-- generated by `keel map`"))
.collect::<Vec<_>>()
.join("\n")
}
fn conventions_section(paths: &Paths, cfg: &Config) -> Result<Option<Section>> {
let mut body = String::new();
let mut sources: Vec<String> = Vec::new();
for sh in store::shared(paths, cfg) {
if sh.missing {
body.push_str(&format!(
"> **Shared store `{}` did not load** from `{}`. Rules it carries are \
NOT in force in this render.\n\n",
sh.id,
sh.root.display()
));
continue;
}
let Some(doc) = StoreDoc::read_optional(&sh.conventions())? else { continue };
let text = doc.body_without_title().trim().to_string();
if text.is_empty() { continue; }
body.push_str(&format!("### From `{}` (shared)\n\n{}\n\n", sh.id, text));
sources.push(sh.conventions().to_string_lossy().to_string());
}
if let Some(doc) = StoreDoc::read_optional(&paths.conventions())? {
let text = strip_generated_notice(doc.body_without_title());
if !text.trim().is_empty() {
if !sources.is_empty() {
body.push_str("### This repository\n\n");
}
body.push_str(text.trim());
body.push('\n');
}
sources.push(paths.rel(&paths.conventions()).to_string_lossy().to_string());
}
if body.trim().is_empty() { return Ok(None); }
Ok(Some(Section {
title: "House rules".to_string(),
source: sources.join(", "),
body: demote_headings(&body).trim().to_string(),
}))
}
fn lessons_section(paths: &Paths, cfg: &Config) -> Result<Option<Section>> {
let lessons = crate::lesson::list_all(paths, cfg)?;
if lessons.is_empty() { return Ok(None); }
let mut body = String::new();
for entry in &lessons {
let l = &entry.lesson;
let origin = match &entry.from {
Some(id) => format!("{}, shared:{id}", l.front.scope),
None => l.front.scope.clone(),
};
let rule = l.rule().unwrap_or_else(|| first_prose_line(&l.body));
body.push_str(&format!("- **{}** ({origin}) — {rule}\n", l.front.id));
}
Ok(Some(Section {
title: "Lessons in force".into(),
source: ".keel/store/lessons/".into(),
body: body.trim_end().to_string(),
}))
}
fn first_prose_line(body: &str) -> String {
body.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with("---"))
.unwrap_or("(no rule stated)")
.to_string()
}
pub fn render_builtin(paths: &Paths, cfg: &Config, adapter: &Adapter) -> Result<(String, bool)> {
let sections = collect(paths, cfg, adapter)?;
let preamble = preamble(adapter);
let per_section_overhead = 3;
let fixed = preamble.lines().count() + sections.len() * per_section_overhead;
let available = adapter.budget.saturating_sub(fixed);
let allocations = allocate(§ions, available);
let mut trimmed_any = false;
let mut out = preamble;
for (section, alloc) in sections.iter().zip(allocations.iter()) {
out.push_str(&format!("\n## {}\n\n", section.title));
if section.lines() <= *alloc {
out.push_str(section.body.trim_end());
out.push('\n');
} else {
trimmed_any = true;
out.push_str(&trim_to(§ion.body, *alloc, §ion.source));
}
}
let out = hard_clip(out, adapter.budget);
Ok((out, trimmed_any))
}
fn hard_clip(s: String, budget: usize) -> String {
let total = s.lines().count();
if total <= budget { return s; }
if budget == 0 { return String::new(); }
let keep = budget - 1;
let mut out: String = s.lines().take(keep).collect::<Vec<_>>().join("\n");
if !out.is_empty() { out.push('\n'); }
out.push_str(&format!("_… {} lines cut at the budget; see `.keel/store/`._\n", total - keep));
out
}
fn preamble(adapter: &Adapter) -> String {
format!(
"# Project context\n\n\
Generated by keel for `{}`. The canonical source is `.keel/store/`; \
do not edit this file. Anything trimmed below is deferred, not deleted — \
open the cited path when you need it.\n",
adapter.id
)
}
fn allocate(sections: &[Section], available: usize) -> Vec<usize> {
let n = sections.len();
let mut alloc = vec![0usize; n];
if n == 0 { return alloc; }
let mut unsatisfied: Vec<usize> = (0..n).collect();
let mut remaining = available;
while !unsatisfied.is_empty() && remaining > 0 {
let weights: Vec<f64> = unsatisfied
.iter()
.enumerate()
.map(|(rank, _)| 1.0 / (rank as f64 + 1.5))
.collect();
let total: f64 = weights.iter().sum();
let mut progressed = false;
for (k, &i) in unsatisfied.iter().enumerate() {
let share = ((remaining as f64) * weights[k] / total).floor() as usize;
if sections[i].lines() <= share {
alloc[i] = sections[i].lines();
progressed = true;
}
}
if progressed {
let satisfied: Vec<usize> = unsatisfied.iter().copied().filter(|i| alloc[*i] > 0).collect();
for i in &satisfied {
remaining = remaining.saturating_sub(alloc[*i]);
}
unsatisfied.retain(|i| alloc[*i] == 0);
continue;
}
for (k, &i) in unsatisfied.iter().enumerate() {
alloc[i] = ((remaining as f64) * weights[k] / total).floor() as usize;
}
break;
}
alloc
}
fn trim_to(body: &str, budget: usize, source: &str) -> String {
let pointer = format!("_… more in `{source}`._\n");
if budget == 0 {
return String::new();
}
if budget == 1 {
return pointer;
}
let mut content = budget - 2;
let mut taken: Vec<&str> = body.lines().take(content).collect();
let unbalanced = |ls: &[&str]| ls.iter().filter(|l| l.trim_start().starts_with("```")).count() % 2 == 1;
if unbalanced(&taken) {
content = content.saturating_sub(1);
taken = body.lines().take(content).collect();
}
let omitted = body.lines().count().saturating_sub(content);
let mut out = taken.join("\n");
if !out.is_empty() { out.push('\n'); }
if unbalanced(&taken) {
out.push_str("```\n");
}
out.push('\n');
out.push_str(&format!("_… {omitted} more lines in `{source}`._\n"));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sections(sizes: &[usize]) -> Vec<Section> {
sizes
.iter()
.enumerate()
.map(|(i, n)| Section {
title: format!("S{i}"),
source: format!("store/s{i}.md"),
body: (0..*n).map(|j| format!("line {j}")).collect::<Vec<_>>().join("\n"),
})
.collect()
}
#[test]
fn headings_are_demoted_but_not_inside_fences() {
let body = "# Title\n## Sub\ntext\n```sh\n# not a heading\n```\n### Deep\n";
let out = demote_headings(body);
assert!(out.contains("\n### Sub"), "{out}");
assert!(out.starts_with("## Title"), "{out}");
assert!(out.contains("\n# not a heading"), "fence content was demoted:\n{out}");
assert!(out.contains("\n#### Deep"), "{out}");
}
#[test]
fn demotion_stops_at_the_deepest_level() {
let out = demote_headings("###### Deepest\n");
assert_eq!(out.trim(), "###### Deepest");
}
#[test]
fn everything_fits_when_budget_is_ample() {
let s = sections(&[3, 4, 5]);
let a = allocate(&s, 100);
assert_eq!(a, vec![3, 4, 5]);
}
#[test]
fn allocation_never_exceeds_available() {
for available in [0usize, 1, 5, 9, 20, 60] {
let s = sections(&[40, 30, 20, 10]);
let a = allocate(&s, available);
let total: usize = a.iter().sum();
assert!(total <= available, "allocated {total} of {available}: {a:?}");
}
}
#[test]
fn priority_wins_under_pressure() {
let s = sections(&[40, 40, 40]);
let a = allocate(&s, 12);
assert!(a[0] > a[2], "first section should get the most: {a:?}");
}
#[test]
fn small_low_priority_sections_still_survive() {
let s = sections(&[200, 2]);
let a = allocate(&s, 30);
assert!(a[1] >= 2, "tiny section was starved: {a:?}");
}
#[test]
fn trimming_closes_open_code_fences() {
let body = "text\n```rust\nlet x = 1;\nlet y = 2;\n```\nmore\n";
let out = trim_to(body, 5, "store/x.md");
assert_eq!(out.matches("```").count() % 2, 0, "unbalanced fences in:\n{out}");
assert!(out.contains("more lines in `store/x.md`"));
}
#[test]
fn trimming_never_exceeds_its_budget() {
let plain: String = (0..50).map(|i| format!("line {i}\n")).collect();
let fenced = "intro\n```rust\nlet a = 1;\nlet b = 2;\nlet c = 3;\n```\ntail\n";
for body in [plain.as_str(), fenced] {
for budget in 0..12usize {
let out = trim_to(body, budget, "store/x.md");
assert!(
out.lines().count() <= budget,
"budget {budget} produced {} lines:\n{out}", out.lines().count()
);
}
}
}
#[test]
fn a_budget_of_one_still_points_at_the_source() {
let out = trim_to("a\nb\n", 1, "store/x.md");
assert_eq!(out.lines().count(), 1);
assert!(out.contains("store/x.md"));
}
#[test]
fn hard_clip_is_exact() {
let s: String = (0..40).map(|i| format!("l{i}\n")).collect();
for budget in 0..20usize {
let out = hard_clip(s.clone(), budget);
assert!(out.lines().count() <= budget, "budget {budget}");
}
}
}