Skip to main content

kdo_context/
generate.rs

1//! CONTEXT.md generation and token-budgeted context bundles.
2
3use 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/// A token-budgeted context bundle for a project.
11#[derive(Debug, Serialize, Deserialize)]
12pub struct ContextBundle {
13    /// Project name.
14    pub project: String,
15    /// One-line summary.
16    pub summary: Option<String>,
17    /// Public API signatures grouped by kind.
18    pub signatures: Vec<Signature>,
19    /// Dependency names.
20    pub dependencies: Vec<String>,
21    /// Total estimated tokens used.
22    pub tokens_used: usize,
23    /// Token budget applied.
24    pub budget: usize,
25    /// Whether output was truncated.
26    pub truncated: bool,
27    /// Number of signatures omitted due to budget.
28    pub omitted_count: usize,
29}
30
31/// Generate a context bundle for a project within a token budget.
32///
33/// Tiers:
34/// 1. Summary + dependency list (always included)
35/// 2. Public API signatures (functions, structs, traits)
36/// 3. Implementation details (truncated first)
37pub 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    // Collect source files
47    let source_files = collect_source_files(&project.path, &project.language);
48
49    // Extract all signatures
50    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    // Build the bundle with token budget enforcement
57    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    // Tier 1: Summary + deps (always included)
63    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; // Header overhead
71
72    // Tier 2: Signatures by priority
73    // Functions first, then structs/enums, then traits, then others
74    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; // formatting overhead
94        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
124/// Render a context bundle as CONTEXT.md markdown.
125pub 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    // Group by kind
136    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
224/// Collect source files for a project based on its language.
225fn 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}