Skip to main content

ucp_codegraph/
projection.rs

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}