1use std::fmt::Write as _;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use ucm_core::{Block, BlockId, Content, Document, EdgeType};
6
7use crate::model::{META_CODEREF, META_LANGUAGE, META_LOGICAL_KEY, META_NODE_CLASS};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CodeGraphPromptProjectionConfig {
11 #[serde(default = "default_max_files")]
12 pub max_files: usize,
13 #[serde(default = "default_max_symbols_total")]
14 pub max_symbols_total: usize,
15 #[serde(default = "default_max_symbols_per_file")]
16 pub max_symbols_per_file: usize,
17 #[serde(default = "default_max_edges_per_symbol")]
18 pub max_edges_per_symbol: usize,
19}
20
21impl Default for CodeGraphPromptProjectionConfig {
22 fn default() -> Self {
23 Self {
24 max_files: default_max_files(),
25 max_symbols_total: default_max_symbols_total(),
26 max_symbols_per_file: default_max_symbols_per_file(),
27 max_edges_per_symbol: default_max_edges_per_symbol(),
28 }
29 }
30}
31
32const fn default_max_files() -> usize {
33 40
34}
35
36const fn default_max_symbols_total() -> usize {
37 160
38}
39
40const fn default_max_symbols_per_file() -> usize {
41 8
42}
43
44const fn default_max_edges_per_symbol() -> usize {
45 4
46}
47
48pub fn codegraph_prompt_projection(doc: &Document) -> String {
49 codegraph_prompt_projection_with_config(doc, &CodeGraphPromptProjectionConfig::default())
50}
51
52pub fn codegraph_prompt_projection_with_config(
53 doc: &Document,
54 config: &CodeGraphPromptProjectionConfig,
55) -> String {
56 let repo = repository_block(doc);
57 let mut file_ids = file_block_ids(doc);
58 let file_total = file_ids.len();
59 if file_ids.len() > config.max_files {
60 file_ids.truncate(config.max_files);
61 }
62
63 let total_edges: usize = doc.blocks.values().map(|block| block.edges.len()).sum();
64 let total_symbols = doc
65 .blocks
66 .values()
67 .filter(|block| node_class(block).as_deref() == Some("symbol"))
68 .count();
69
70 let mut out = String::new();
71 out.push_str("CodeGraph projection\n");
72 if let Some(block) = repo {
73 let name = content_string(block, "name").unwrap_or_else(|| "repository".to_string());
74 let coderef = content_coderef_display(block).or_else(|| metadata_coderef_display(block));
75 let _ = writeln!(
76 out,
77 "repo: {}{}",
78 name,
79 coderef
80 .map(|value| format!(" @ {value}"))
81 .unwrap_or_default()
82 );
83 }
84 let _ = writeln!(
85 out,
86 "summary: files={} symbols={} edges={}",
87 file_total, total_symbols, total_edges
88 );
89
90 if !file_ids.is_empty() {
91 out.push_str("\nfiles:\n");
92 }
93
94 let mut emitted_symbols = 0usize;
95 for file_id in file_ids {
96 let Some(file_block) = doc.get_block(&file_id) else {
97 continue;
98 };
99 let path = content_coderef_display(file_block)
100 .or_else(|| metadata_coderef_display(file_block))
101 .unwrap_or_else(|| block_logical_key(file_block).unwrap_or_else(|| "file".to_string()));
102 let language = file_block
103 .metadata
104 .custom
105 .get(META_LANGUAGE)
106 .and_then(|value| value.as_str())
107 .unwrap_or("unknown");
108 let _ = writeln!(out, "- file {} [{}]", path, language);
109 if let Some(description) = content_string(file_block, "description") {
110 let _ = writeln!(out, " docs: {}", description);
111 }
112
113 let descendants = symbol_descendants(doc, file_id);
114 let remaining_total = config.max_symbols_total.saturating_sub(emitted_symbols);
115 let take = descendants
116 .len()
117 .min(config.max_symbols_per_file)
118 .min(remaining_total);
119 for symbol_id in descendants.into_iter().take(take) {
120 emitted_symbols += 1;
121 render_symbol(doc, &mut out, &symbol_id, 1, config.max_edges_per_symbol);
122 }
123
124 if emitted_symbols >= config.max_symbols_total {
125 let omitted = total_symbols.saturating_sub(emitted_symbols);
126 if omitted > 0 {
127 let _ = writeln!(out, " … {} more symbols omitted by budget", omitted);
128 }
129 break;
130 }
131 }
132
133 if file_total > config.max_files {
134 let _ = writeln!(
135 out,
136 "\n… {} more files omitted by budget",
137 file_total - config.max_files
138 );
139 }
140
141 out.trim_end().to_string()
142}
143
144fn render_symbol(
145 doc: &Document,
146 out: &mut String,
147 symbol_id: &BlockId,
148 indent: usize,
149 max_edges_per_symbol: usize,
150) {
151 let Some(block) = doc.get_block(symbol_id) else {
152 return;
153 };
154 let pad = " ".repeat(indent);
155 let label = format_symbol_signature(block);
156 let coderef = content_coderef_display(block)
157 .or_else(|| metadata_coderef_display(block))
158 .unwrap_or_else(|| block_logical_key(block).unwrap_or_else(|| "symbol".to_string()));
159 let modifiers = format_symbol_modifiers(block);
160 let _ = writeln!(out, "{}- {}{} @ {}", pad, label, modifiers, coderef);
161 if let Some(description) =
162 content_string(block, "description").or_else(|| block.metadata.summary.clone())
163 {
164 let _ = writeln!(out, "{} docs: {}", pad, description);
165 }
166
167 let mut edges = rendered_edges(doc, block);
168 if edges.len() > max_edges_per_symbol {
169 edges.truncate(max_edges_per_symbol);
170 }
171 for edge in edges {
172 let _ = writeln!(out, "{} edge: {}", pad, edge);
173 }
174
175 for child in child_symbol_ids(doc, *symbol_id) {
176 render_symbol(doc, out, &child, indent + 1, max_edges_per_symbol);
177 }
178}
179
180fn rendered_edges(doc: &Document, block: &Block) -> Vec<String> {
181 let mut rendered = block
182 .edges
183 .iter()
184 .map(|edge| {
185 let relation = edge
186 .metadata
187 .custom
188 .get("relation")
189 .and_then(|value| value.as_str())
190 .or(match &edge.edge_type {
191 EdgeType::Custom(value) => Some(value.as_str()),
192 _ => None,
193 })
194 .unwrap_or("edge");
195 let target = doc
196 .get_block(&edge.target)
197 .and_then(block_logical_key)
198 .or_else(|| {
199 edge.metadata
200 .custom
201 .get("raw_target")
202 .and_then(|value| value.as_str())
203 .map(|value| value.to_string())
204 })
205 .unwrap_or_else(|| edge.target.to_string());
206 format!("{} -> {}", relation, target)
207 })
208 .collect::<Vec<_>>();
209 rendered.sort();
210 rendered.dedup();
211 rendered
212}
213
214fn repository_block(doc: &Document) -> Option<&Block> {
215 doc.blocks
216 .values()
217 .find(|block| node_class(block).as_deref() == Some("repository"))
218}
219
220fn file_block_ids(doc: &Document) -> Vec<BlockId> {
221 let mut files = doc
222 .blocks
223 .iter()
224 .filter(|(_, block)| node_class(block).as_deref() == Some("file"))
225 .map(|(id, _)| *id)
226 .collect::<Vec<_>>();
227 files.sort_by_key(|id| {
228 doc.get_block(id)
229 .and_then(content_coderef_display)
230 .or_else(|| doc.get_block(id).and_then(metadata_coderef_display))
231 .unwrap_or_else(|| id.to_string())
232 });
233 files
234}
235
236fn symbol_descendants(doc: &Document, root: BlockId) -> Vec<BlockId> {
237 let mut out = Vec::new();
238 let mut stack = doc.children(&root).to_vec();
239 while let Some(block_id) = stack.pop() {
240 let Some(block) = doc.get_block(&block_id) else {
241 continue;
242 };
243 if node_class(block).as_deref() == Some("symbol") {
244 out.push(block_id);
245 }
246 let mut children = doc.children(&block_id).to_vec();
247 children.reverse();
248 stack.extend(children);
249 }
250 out.sort_by_key(|id| sort_key_for_block(doc, id));
251 out
252}
253
254fn child_symbol_ids(doc: &Document, root: BlockId) -> Vec<BlockId> {
255 let mut children = doc
256 .children(&root)
257 .iter()
258 .copied()
259 .filter(|child| {
260 doc.get_block(child)
261 .map(|block| node_class(block).as_deref() == Some("symbol"))
262 .unwrap_or(false)
263 })
264 .collect::<Vec<_>>();
265 children.sort_by_key(|id| sort_key_for_block(doc, id));
266 children
267}
268
269fn sort_key_for_block(doc: &Document, block_id: &BlockId) -> (String, String) {
270 let Some(block) = doc.get_block(block_id) else {
271 return (String::new(), block_id.to_string());
272 };
273 (
274 content_coderef_display(block)
275 .or_else(|| metadata_coderef_display(block))
276 .unwrap_or_default(),
277 block_logical_key(block).unwrap_or_else(|| block_id.to_string()),
278 )
279}
280
281fn format_symbol_signature(block: &Block) -> String {
282 let kind = content_string(block, "kind").unwrap_or_else(|| "symbol".to_string());
283 let name = content_string(block, "name").unwrap_or_else(|| "unknown".to_string());
284 let inputs = content_array(block, "inputs")
285 .into_iter()
286 .map(|value| {
287 let name = value.get("name").and_then(Value::as_str).unwrap_or("_");
288 match value.get("type").and_then(Value::as_str) {
289 Some(type_name) => format!("{}: {}", name, type_name),
290 None => name.to_string(),
291 }
292 })
293 .collect::<Vec<_>>();
294 let output = content_string(block, "output");
295 let type_info = content_string(block, "type");
296 match kind.as_str() {
297 "function" | "method" => {
298 let mut rendered = format!("{} {}({})", kind, name, inputs.join(", "));
299 if let Some(output) = output {
300 let _ = write!(rendered, " -> {}", output);
301 }
302 rendered
303 }
304 _ => {
305 let mut rendered = format!("{} {}", kind, name);
306 if let Some(type_info) = type_info {
307 let _ = write!(rendered, " : {}", type_info);
308 }
309 rendered
310 }
311 }
312}
313
314fn format_symbol_modifiers(block: &Block) -> String {
315 let Content::Json { value, .. } = &block.content else {
316 return String::new();
317 };
318 let Some(modifiers) = value.get("modifiers").and_then(Value::as_object) else {
319 return String::new();
320 };
321
322 let mut parts = Vec::new();
323 if modifiers.get("async").and_then(Value::as_bool) == Some(true) {
324 parts.push("async".to_string());
325 }
326 if modifiers.get("static").and_then(Value::as_bool) == Some(true) {
327 parts.push("static".to_string());
328 }
329 if modifiers.get("generator").and_then(Value::as_bool) == Some(true) {
330 parts.push("generator".to_string());
331 }
332 if let Some(visibility) = modifiers.get("visibility").and_then(Value::as_str) {
333 parts.push(visibility.to_string());
334 }
335
336 if parts.is_empty() {
337 String::new()
338 } else {
339 format!(" [{}]", parts.join(", "))
340 }
341}
342
343fn content_string(block: &Block, field: &str) -> Option<String> {
344 let Content::Json { value, .. } = &block.content else {
345 return None;
346 };
347 value.get(field)?.as_str().map(|value| value.to_string())
348}
349
350fn content_array(block: &Block, field: &str) -> Vec<Value> {
351 let Content::Json { value, .. } = &block.content else {
352 return Vec::new();
353 };
354 value
355 .get(field)
356 .and_then(Value::as_array)
357 .cloned()
358 .unwrap_or_default()
359}
360
361fn node_class(block: &Block) -> Option<String> {
362 block
363 .metadata
364 .custom
365 .get(META_NODE_CLASS)
366 .and_then(Value::as_str)
367 .map(|value| value.to_string())
368}
369
370fn block_logical_key(block: &Block) -> Option<String> {
371 block
372 .metadata
373 .custom
374 .get(META_LOGICAL_KEY)
375 .and_then(Value::as_str)
376 .map(|value| value.to_string())
377}
378
379fn metadata_coderef_display(block: &Block) -> Option<String> {
380 block
381 .metadata
382 .custom
383 .get(META_CODEREF)
384 .and_then(|value| value.get("display"))
385 .and_then(Value::as_str)
386 .map(|value| value.to_string())
387}
388
389fn content_coderef_display(block: &Block) -> Option<String> {
390 let Content::Json { value, .. } = &block.content else {
391 return None;
392 };
393 value
394 .get("coderef")
395 .and_then(|value| value.get("display"))
396 .and_then(Value::as_str)
397 .map(|value| value.to_string())
398}
399
400#[cfg(test)]
401mod tests {
402 use std::fs;
403
404 use tempfile::tempdir;
405
406 use super::*;
407 use crate::{build_code_graph, CodeGraphBuildInput, CodeGraphExtractorConfig};
408
409 #[test]
410 fn prompt_projection_renders_compact_codegraph_view() {
411 let dir = tempdir().unwrap();
412 fs::create_dir_all(dir.path().join("src")).unwrap();
413 fs::write(
414 dir.path().join("src/util.rs"),
415 "pub fn util() -> i32 { 1 }\n",
416 )
417 .unwrap();
418 fs::write(
419 dir.path().join("src/lib.rs"),
420 "mod util;\n/// Add values.\npub async fn add(a: i32, b: i32) -> i32 { util::util() + a + b }\n",
421 )
422 .unwrap();
423
424 let build = build_code_graph(&CodeGraphBuildInput {
425 repository_path: dir.path().to_path_buf(),
426 commit_hash: "projection".to_string(),
427 config: CodeGraphExtractorConfig::default(),
428 })
429 .unwrap();
430
431 let projection = codegraph_prompt_projection(&build.document);
432 assert!(projection.contains("CodeGraph projection"));
433 assert!(projection.contains("- file src/lib.rs [rust]"));
434 assert!(projection.contains("function add(a: i32, b: i32) -> i32 [async, public]"));
435 assert!(projection.contains("docs: Add values."));
436 assert!(projection.contains("edge: uses_symbol -> symbol:src/util.rs::util"));
437 }
438}