lean_ctx/core/knowledge/
agents_md.rs1use super::{ProjectKnowledge, is_machine_derived};
17use crate::core::sensitivity::SensitivityLevel;
18
19pub const BEGIN: &str = "<!-- lean-ctx:knowledge:begin (generated by `lean-ctx knowledge export-agents-md`; edits inside are replaced) -->";
21pub const END: &str = "<!-- lean-ctx:knowledge:end -->";
23
24pub const SECTIONS: [(&str, &str, &[&str]); 7] = [
27 (
28 "convention",
29 "Conventions",
30 &["convention", "conventions", "style"],
31 ),
32 (
33 "procedure",
34 "Procedures",
35 &["procedure", "procedures", "workflow", "workflows"],
36 ),
37 ("decision", "Decisions", &["decision", "decisions"]),
38 ("architecture", "Architecture", &["architecture", "arch"]),
39 (
40 "gotcha",
41 "Gotchas",
42 &["gotcha", "gotchas", "known_issues", "known_bugs"],
43 ),
44 (
45 "dead_end",
46 "Dead ends",
47 &["dead_end", "dead_ends", "dead-end", "dead-ends"],
48 ),
49 ("experience", "Experiences", &["experience", "experiences"]),
50];
51
52fn one_line(value: &str) -> String {
53 value.split_whitespace().collect::<Vec<_>>().join(" ")
54}
55
56#[must_use]
58pub fn render(knowledge: &ProjectKnowledge) -> String {
59 render_with_hive(knowledge, false)
60}
61
62#[must_use]
70pub fn render_with_hive(knowledge: &ProjectKnowledge, include_hive: bool) -> String {
71 let mut out = format!("{BEGIN}\n## Project knowledge (lean-ctx)\n");
72 for (_, heading, categories) in SECTIONS {
73 let mut values: Vec<String> = knowledge
74 .facts
75 .iter()
76 .filter(|fact| {
77 fact.is_current()
79 && fact.hive_origin().is_none()
80 && fact.sensitivity == SensitivityLevel::Public
81 && !is_machine_derived(&fact.category, &fact.key)
82 && categories.contains(&fact.category.to_lowercase().as_str())
83 })
84 .map(|fact| one_line(&fact.value))
85 .filter(|value| !value.is_empty())
86 .collect();
87 values.sort();
88 values.dedup();
89 if values.is_empty() {
90 continue;
91 }
92 out.push_str(&format!("\n### {heading}\n\n"));
93 for value in values {
94 out.push_str(&format!("- {value}\n"));
95 }
96 }
97 if include_hive {
98 let mut team: Vec<String> = knowledge
99 .facts
100 .iter()
101 .filter(|fact| {
102 fact.is_current()
103 && fact.review.is_none()
104 && fact.sensitivity == SensitivityLevel::Public
105 })
106 .filter_map(|fact| {
107 let origin = fact.hive_origin()?;
108 let text = crate::core::untrusted::body_of(&fact.value)
109 .unwrap_or_else(|| fact.value.clone());
110 let text = one_line(&text);
111 let source = super::import_tag::import_tag_source(origin)
113 .and_then(|rest| rest.rsplit_once("@r"))
114 .map_or_else(
115 || origin.to_string(),
116 |(repo, rev)| format!("{repo} r{rev}"),
117 );
118 (!text.is_empty()).then(|| format!("{text} (Hive {source})"))
119 })
120 .collect();
121 team.sort();
122 team.dedup();
123 if !team.is_empty() {
124 out.push_str("\n### Team knowledge from Hive (reviewed)\n\n");
125 for line in team {
126 out.push_str(&format!("- {line}\n"));
127 }
128 }
129 }
130 out.push('\n');
131 out.push_str(END);
132 out.push('\n');
133 out
134}
135
136#[must_use]
139pub fn merge_into(existing: Option<&str>, section: &str) -> String {
140 let Some(existing) = existing.filter(|text| !text.trim().is_empty()) else {
141 return section.to_owned();
142 };
143 if let (Some(start), Some(end)) = (existing.find(BEGIN), existing.find(END))
144 && start < end
145 {
146 let tail = &existing[end + END.len()..];
147 let tail = tail.strip_prefix('\n').unwrap_or(tail);
148 return format!("{}{section}{tail}", &existing[..start]);
149 }
150 let mut out = existing.trim_end().to_owned();
151 out.push_str("\n\n");
152 out.push_str(section);
153 out
154}
155
156#[cfg(test)]
157#[path = "agents_md_tests.rs"]
158mod tests;