use super::{ProjectKnowledge, is_machine_derived};
use crate::core::sensitivity::SensitivityLevel;
pub const BEGIN: &str = "<!-- lean-ctx:knowledge:begin (generated by `lean-ctx knowledge export-agents-md`; edits inside are replaced) -->";
pub const END: &str = "<!-- lean-ctx:knowledge:end -->";
pub const SECTIONS: [(&str, &str, &[&str]); 7] = [
(
"convention",
"Conventions",
&["convention", "conventions", "style"],
),
(
"procedure",
"Procedures",
&["procedure", "procedures", "workflow", "workflows"],
),
("decision", "Decisions", &["decision", "decisions"]),
("architecture", "Architecture", &["architecture", "arch"]),
(
"gotcha",
"Gotchas",
&["gotcha", "gotchas", "known_issues", "known_bugs"],
),
(
"dead_end",
"Dead ends",
&["dead_end", "dead_ends", "dead-end", "dead-ends"],
),
("experience", "Experiences", &["experience", "experiences"]),
];
fn one_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[must_use]
pub fn render(knowledge: &ProjectKnowledge) -> String {
render_with_hive(knowledge, false)
}
#[must_use]
pub fn render_with_hive(knowledge: &ProjectKnowledge, include_hive: bool) -> String {
let mut out = format!("{BEGIN}\n## Project knowledge (lean-ctx)\n");
for (_, heading, categories) in SECTIONS {
let mut values: Vec<String> = knowledge
.facts
.iter()
.filter(|fact| {
fact.is_current()
&& fact.hive_origin().is_none()
&& fact.sensitivity == SensitivityLevel::Public
&& !is_machine_derived(&fact.category, &fact.key)
&& categories.contains(&fact.category.to_lowercase().as_str())
})
.map(|fact| one_line(&fact.value))
.filter(|value| !value.is_empty())
.collect();
values.sort();
values.dedup();
if values.is_empty() {
continue;
}
out.push_str(&format!("\n### {heading}\n\n"));
for value in values {
out.push_str(&format!("- {value}\n"));
}
}
if include_hive {
let mut team: Vec<String> = knowledge
.facts
.iter()
.filter(|fact| {
fact.is_current()
&& fact.review.is_none()
&& fact.sensitivity == SensitivityLevel::Public
})
.filter_map(|fact| {
let origin = fact.hive_origin()?;
let text = crate::core::untrusted::body_of(&fact.value)
.unwrap_or_else(|| fact.value.clone());
let text = one_line(&text);
let source = super::import_tag::import_tag_source(origin)
.and_then(|rest| rest.rsplit_once("@r"))
.map_or_else(
|| origin.to_string(),
|(repo, rev)| format!("{repo} r{rev}"),
);
(!text.is_empty()).then(|| format!("{text} (Hive {source})"))
})
.collect();
team.sort();
team.dedup();
if !team.is_empty() {
out.push_str("\n### Team knowledge from Hive (reviewed)\n\n");
for line in team {
out.push_str(&format!("- {line}\n"));
}
}
}
out.push('\n');
out.push_str(END);
out.push('\n');
out
}
#[must_use]
pub fn merge_into(existing: Option<&str>, section: &str) -> String {
let Some(existing) = existing.filter(|text| !text.trim().is_empty()) else {
return section.to_owned();
};
if let (Some(start), Some(end)) = (existing.find(BEGIN), existing.find(END))
&& start < end
{
let tail = &existing[end + END.len()..];
let tail = tail.strip_prefix('\n').unwrap_or(tail);
return format!("{}{section}{tail}", &existing[..start]);
}
let mut out = existing.trim_end().to_owned();
out.push_str("\n\n");
out.push_str(section);
out
}
#[cfg(test)]
#[path = "agents_md_tests.rs"]
mod tests;