1use crate::extract::{extract_signatures, Signature, SignatureKind};
4use kdo_core::{estimate_tokens, Language};
5use kdo_graph::WorkspaceGraph;
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use tracing::debug;
9
10#[derive(Debug, Serialize, Deserialize)]
12pub struct ContextBundle {
13 pub project: String,
15 pub summary: Option<String>,
17 pub signatures: Vec<Signature>,
19 pub dependencies: Vec<String>,
21 pub tokens_used: usize,
23 pub budget: usize,
25 pub truncated: bool,
27 pub omitted_count: usize,
29}
30
31pub fn generate_context(
38 graph: &WorkspaceGraph,
39 project_name: &str,
40 budget: usize,
41) -> Result<ContextBundle, kdo_core::KdoError> {
42 let project = graph.get_project(project_name)?;
43 let deps = graph.dependency_closure(project_name)?;
44 let dep_names: Vec<String> = deps.iter().map(|d| d.name.clone()).collect();
45
46 let source_files = collect_source_files(&project.path, &project.language);
48
49 let mut all_sigs: Vec<Signature> = Vec::new();
51 for file in &source_files {
52 let sigs = extract_signatures(file, &project.language);
53 all_sigs.extend(sigs);
54 }
55
56 let mut tokens_used = 0;
58 let mut included_sigs = Vec::new();
59 let mut truncated = false;
60 let mut omitted_count = 0;
61
62 let summary_text = project
64 .context_summary
65 .as_deref()
66 .unwrap_or("No description");
67 tokens_used += estimate_tokens(summary_text);
68 let deps_text = dep_names.join(", ");
69 tokens_used += estimate_tokens(&deps_text);
70 tokens_used += 50; let priority_order = [
75 SignatureKind::Function,
76 SignatureKind::Struct,
77 SignatureKind::Enum,
78 SignatureKind::Trait,
79 SignatureKind::TypeAlias,
80 SignatureKind::Impl,
81 SignatureKind::Constant,
82 ];
83
84 let mut sorted_sigs = all_sigs.clone();
85 sorted_sigs.sort_by_key(|sig| {
86 priority_order
87 .iter()
88 .position(|k| k == &sig.kind)
89 .unwrap_or(99)
90 });
91
92 for sig in &sorted_sigs {
93 let sig_tokens = estimate_tokens(&sig.text) + 5; if tokens_used + sig_tokens > budget {
95 truncated = true;
96 omitted_count += 1;
97 } else {
98 tokens_used += sig_tokens;
99 included_sigs.push(sig.clone());
100 }
101 }
102
103 debug!(
104 project = project_name,
105 total_sigs = all_sigs.len(),
106 included = included_sigs.len(),
107 tokens = tokens_used,
108 budget = budget,
109 "generated context bundle"
110 );
111
112 Ok(ContextBundle {
113 project: project_name.to_string(),
114 summary: project.context_summary.clone(),
115 signatures: included_sigs,
116 dependencies: dep_names,
117 tokens_used,
118 budget,
119 truncated,
120 omitted_count,
121 })
122}
123
124pub fn render_context_md(bundle: &ContextBundle) -> String {
126 let mut md = String::new();
127 md.push_str(&format!("# {}\n\n", bundle.project));
128
129 if let Some(summary) = &bundle.summary {
130 md.push_str(&format!("> {summary}\n\n"));
131 }
132
133 md.push_str("## Public API\n\n");
134
135 let mut functions = Vec::new();
137 let mut structs = Vec::new();
138 let mut enums = Vec::new();
139 let mut traits = Vec::new();
140 let mut type_aliases = Vec::new();
141 let mut others = Vec::new();
142
143 for sig in &bundle.signatures {
144 match sig.kind {
145 SignatureKind::Function => functions.push(sig),
146 SignatureKind::Struct => structs.push(sig),
147 SignatureKind::Enum => enums.push(sig),
148 SignatureKind::Trait => traits.push(sig),
149 SignatureKind::TypeAlias => type_aliases.push(sig),
150 _ => others.push(sig),
151 }
152 }
153
154 if !functions.is_empty() {
155 md.push_str("### Functions\n\n");
156 for sig in &functions {
157 md.push_str(&format!("- `{}`\n", sig.text.replace('\n', " ")));
158 }
159 md.push('\n');
160 }
161
162 if !structs.is_empty() {
163 md.push_str("### Structs\n\n");
164 for sig in &structs {
165 md.push_str(&format!("- `{}`\n", first_line(&sig.text)));
166 }
167 md.push('\n');
168 }
169
170 if !enums.is_empty() {
171 md.push_str("### Enums\n\n");
172 for sig in &enums {
173 md.push_str(&format!("- `{}`\n", first_line(&sig.text)));
174 }
175 md.push('\n');
176 }
177
178 if !traits.is_empty() {
179 md.push_str("### Traits\n\n");
180 for sig in &traits {
181 md.push_str(&format!("- `{}`\n", first_line(&sig.text)));
182 }
183 md.push('\n');
184 }
185
186 if !type_aliases.is_empty() {
187 md.push_str("### Types\n\n");
188 for sig in &type_aliases {
189 md.push_str(&format!("- `{}`\n", sig.text.replace('\n', " ")));
190 }
191 md.push('\n');
192 }
193
194 if !others.is_empty() {
195 md.push_str("### Other\n\n");
196 for sig in &others {
197 md.push_str(&format!("- `{}`\n", first_line(&sig.text)));
198 }
199 md.push('\n');
200 }
201
202 if bundle.truncated {
203 md.push_str(&format!(
204 "\n... [{} more signatures omitted, budget {}/{}]\n",
205 bundle.omitted_count, bundle.tokens_used, bundle.budget
206 ));
207 }
208
209 if !bundle.dependencies.is_empty() {
210 md.push_str("## Dependencies\n\n");
211 for dep in &bundle.dependencies {
212 md.push_str(&format!("- `{dep}`\n"));
213 }
214 md.push('\n');
215 }
216
217 md
218}
219
220fn first_line(s: &str) -> String {
221 s.lines().next().unwrap_or(s).to_string()
222}
223
224fn collect_source_files(project_path: &Path, language: &Language) -> Vec<std::path::PathBuf> {
226 let extensions: &[&str] = match language {
227 Language::Rust | Language::Anchor => &["rs"],
228 Language::TypeScript => &["ts", "tsx"],
229 Language::JavaScript => &["js", "jsx"],
230 Language::Python => &["py"],
231 Language::Go => &["go"],
232 };
233
234 let mut result = Vec::new();
235 let walker = ignore::WalkBuilder::new(project_path)
236 .hidden(true)
237 .git_ignore(true)
238 .add_custom_ignore_filename(".kdoignore")
239 .build();
240
241 for entry in walker.flatten() {
242 let name = entry.file_name().to_string_lossy();
243 if matches!(
244 name.as_ref(),
245 "node_modules" | "target" | ".git" | "dist" | "build" | "__pycache__"
246 ) {
247 continue;
248 }
249 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
250 continue;
251 }
252 let matches_ext = entry
253 .path()
254 .extension()
255 .and_then(|ext| ext.to_str())
256 .map(|ext| extensions.contains(&ext))
257 .unwrap_or(false);
258 if matches_ext {
259 result.push(entry.into_path());
260 }
261 }
262 result
263}