1use crate::graph::{Graph, Node, Edge};
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, Default)]
13pub struct GidContext {
14 pub nodes_touched: Vec<NodeInfo>,
16 pub max_callers: usize,
18 pub total_blast_radius: usize,
20 pub hub_nodes: Vec<NodeInfo>,
22}
23
24#[derive(Debug, Clone)]
26pub struct NodeInfo {
27 pub id: String,
28 pub name: String,
29 pub file: String,
30 pub kind: String,
31 pub callers: usize,
32 pub callees: usize,
33 pub line: Option<usize>,
34}
35
36impl NodeInfo {
37 pub fn from_node(node: &Node, callers: usize, callees: usize) -> Self {
38 let kind = match node.node_kind.as_deref() {
39 Some("File") => "file",
40 Some("Class") | Some("Interface") | Some("Enum") | Some("TypeAlias") | Some("Trait") => "class",
41 Some("Function") | Some("Constant") | Some("Method") => "function",
42 Some("Module") => "module",
43 _ => "unknown",
44 };
45 Self {
46 id: node.id.clone(),
47 name: node.title.clone(),
48 file: node.file_path.as_deref().unwrap_or("").to_string(),
49 kind: kind.to_string(),
50 callers,
51 callees,
52 line: node.start_line,
53 }
54 }
55
56 pub fn from_code_node(node: &Node, callers: usize, callees: usize) -> Self {
58 Self::from_node(node, callers, callees)
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ErrorType {
65 Syntax,
66 Import,
67 Attribute,
68 Assertion,
69 Type,
70 Name,
71 Runtime,
72 Timeout,
73 Unknown,
74}
75
76impl std::fmt::Display for ErrorType {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 ErrorType::Syntax => write!(f, "SyntaxError"),
80 ErrorType::Import => write!(f, "ImportError"),
81 ErrorType::Attribute => write!(f, "AttributeError"),
82 ErrorType::Assertion => write!(f, "AssertionError"),
83 ErrorType::Type => write!(f, "TypeError"),
84 ErrorType::Name => write!(f, "NameError"),
85 ErrorType::Runtime => write!(f, "RuntimeError"),
86 ErrorType::Timeout => write!(f, "Timeout"),
87 ErrorType::Unknown => write!(f, "Unknown"),
88 }
89 }
90}
91
92fn count_callers(node_id: &str, code_edges: &[&Edge]) -> usize {
96 code_edges.iter()
97 .filter(|e| e.to == node_id && e.relation == "calls")
98 .count()
99}
100
101fn count_callees(node_id: &str, code_edges: &[&Edge]) -> usize {
103 code_edges.iter()
104 .filter(|e| e.from == node_id && e.relation == "calls")
105 .count()
106}
107
108fn collect_impacted_nodes<'a>(
110 node_id: &str,
111 code_edges: &[&Edge],
112 graph: &'a Graph,
113 visited: &mut HashSet<String>,
114 result: &mut Vec<&'a Node>,
115) {
116 if !visited.insert(node_id.to_string()) {
117 return;
118 }
119 for edge in code_edges.iter().filter(|e| e.to == node_id) {
120 if let Some(node) = graph.get_node(&edge.from) {
121 result.push(node);
122 collect_impacted_nodes(&edge.from, code_edges, graph, visited, result);
123 }
124 }
125}
126
127fn collect_impacted_nodes_filtered<'a>(
129 node_id: &str,
130 code_edges: &[&Edge],
131 graph: &'a Graph,
132 relations: Option<&[&str]>,
133 visited: &mut HashSet<String>,
134 result: &mut Vec<&'a Node>,
135) {
136 if !visited.insert(node_id.to_string()) {
137 return;
138 }
139 for edge in code_edges.iter().filter(|e| e.to == node_id) {
140 if let Some(rels) = relations {
141 if !rels.contains(&edge.relation.as_str()) {
142 continue;
143 }
144 }
145 if let Some(node) = graph.get_node(&edge.from) {
146 result.push(node);
147 collect_impacted_nodes_filtered(&edge.from, code_edges, graph, relations, visited, result);
148 }
149 }
150}
151
152pub fn query_gid_context(files_changed: &[String], graph: &Graph) -> GidContext {
157 let code_nodes = graph.code_nodes();
158 let code_edges = graph.code_edges();
159 let mut nodes = Vec::new();
160 let mut max_callers = 0;
161 let mut total_blast = 0;
162
163 for file in files_changed {
164 let file_nodes: Vec<&&Node> = code_nodes.iter()
166 .filter(|n| {
167 let fp = n.file_path.as_deref().unwrap_or("");
168 let is_test = n.metadata.get("is_test").and_then(|v| v.as_bool()).unwrap_or(false);
169 let is_func_or_class = matches!(
170 n.node_kind.as_deref(),
171 Some("Function") | Some("Method") | Some("Class")
172 );
173 fp == file.as_str() && !is_test && is_func_or_class
174 })
175 .collect();
176
177 for node in file_nodes {
178 let callers = count_callers(&node.id, &code_edges);
179 let callees = count_callees(&node.id, &code_edges);
180
181 max_callers = max_callers.max(callers);
182 total_blast += callers;
183
184 nodes.push(NodeInfo::from_node(node, callers, callees));
185 }
186 }
187
188 nodes.sort_by(|a, b| b.callers.cmp(&a.callers));
190 nodes.truncate(10);
191
192 let hub_threshold = 10;
194 let hub_nodes: Vec<NodeInfo> = nodes.iter()
195 .filter(|n| n.callers >= hub_threshold)
196 .cloned()
197 .collect();
198
199 GidContext {
200 nodes_touched: nodes,
201 max_callers,
202 total_blast_radius: total_blast,
203 hub_nodes,
204 }
205}
206
207pub fn find_low_risk_alternatives(
210 graph: &Graph,
211 failed_files: &[String],
212 max_callers: usize,
213) -> Vec<NodeInfo> {
214 let code_nodes = graph.code_nodes();
215 let code_edges = graph.code_edges();
216 let mut alternatives = Vec::new();
217
218 let packages: HashSet<String> = failed_files.iter()
220 .filter_map(|f| {
221 f.rsplitn(2, '/').nth(1).map(|s| s.to_string())
222 })
223 .collect();
224
225 for node in &code_nodes {
226 let is_test = node.metadata.get("is_test").and_then(|v| v.as_bool()).unwrap_or(false);
227 if is_test {
228 continue;
229 }
230 if node.node_kind.as_deref() != Some("Function") {
231 continue;
232 }
233
234 let fp = node.file_path.as_deref().unwrap_or("");
235
236 let in_package = packages.iter().any(|pkg| fp.starts_with(pkg));
238 if !in_package {
239 continue;
240 }
241
242 if failed_files.iter().any(|f| f == fp) {
244 continue;
245 }
246
247 let callers = count_callers(&node.id, &code_edges);
248 if callers <= max_callers {
249 let callees = count_callees(&node.id, &code_edges);
250 alternatives.push(NodeInfo::from_node(node, callers, callees));
251 }
252 }
253
254 alternatives.sort_by_key(|n| n.callers);
256 alternatives.truncate(5);
257 alternatives
258}
259
260pub fn classify_error(raw_output: &str) -> ErrorType {
262 let checks: &[(ErrorType, &[&str])] = &[
263 (ErrorType::Syntax, &["SyntaxError:", "SyntaxError("]),
264 (ErrorType::Import, &["ImportError:", "ModuleNotFoundError:"]),
265 (ErrorType::Attribute, &["AttributeError:"]),
266 (ErrorType::Assertion, &["AssertionError:", "AssertionError(", "assert "]),
267 (ErrorType::Type, &["TypeError:"]),
268 (ErrorType::Name, &["NameError:"]),
269 (ErrorType::Timeout, &["TimeoutError", "timed out", "TIMEOUT"]),
270 ];
271
272 let mut best = ErrorType::Unknown;
273 let mut best_count = 0;
274
275 for (etype, patterns) in checks {
276 let count: usize = patterns.iter()
277 .map(|p| raw_output.matches(p).count())
278 .sum();
279 if count > best_count {
280 best_count = count;
281 best = etype.clone();
282 }
283 }
284
285 if best != ErrorType::Syntax && raw_output.contains("SyntaxError:") {
287 return ErrorType::Syntax;
288 }
289
290 best
291}
292
293pub fn extract_key_traceback(raw_output: &str, max_chars: usize) -> String {
295 let traceback_marker = "Traceback (most recent call last)";
296
297 if let Some(pos) = raw_output.find(traceback_marker) {
298 let chunk = &raw_output[pos..];
299 let end = chunk.find("\n\n")
300 .or_else(|| chunk.find("\n====="))
301 .or_else(|| chunk.find("\nFAILED"))
302 .unwrap_or(chunk.len());
303 return chunk[..end.min(max_chars)].to_string();
304 }
305
306 for marker in &["FAIL:", "ERROR:", "FAILED "] {
308 if let Some(pos) = raw_output.find(marker) {
309 let start = pos.saturating_sub(200);
310 let end = (pos + max_chars).min(raw_output.len());
311 return raw_output[start..end].to_string();
312 }
313 }
314
315 let start = raw_output.len().saturating_sub(max_chars);
317 raw_output[start..].to_string()
318}
319
320#[derive(Debug, Clone)]
324pub struct ImpactAnalysis {
325 pub affected_source: Vec<NodeInfo>,
327 pub affected_tests: Vec<NodeInfo>,
329 pub risk_level: RiskLevel,
331 pub summary: String,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub enum RiskLevel {
337 Low, Medium, High, Critical, }
342
343impl std::fmt::Display for RiskLevel {
344 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345 match self {
346 RiskLevel::Low => write!(f, "low"),
347 RiskLevel::Medium => write!(f, "medium"),
348 RiskLevel::High => write!(f, "high"),
349 RiskLevel::Critical => write!(f, "critical"),
350 }
351 }
352}
353
354pub fn analyze_impact(files_changed: &[String], graph: &Graph) -> ImpactAnalysis {
356 let gid_ctx = query_gid_context(files_changed, graph);
357 let code_edges = graph.code_edges();
358
359 let mut affected_source = Vec::new();
360 let mut affected_tests = Vec::new();
361 let mut seen = HashSet::new();
362
363 let changed_node_ids: Vec<String> = graph.code_nodes().iter()
365 .filter(|n| {
366 let fp = n.file_path.as_deref().unwrap_or("");
367 files_changed.iter().any(|f| f == fp)
368 })
369 .map(|n| n.id.clone())
370 .collect();
371
372 for node_id in &changed_node_ids {
374 let mut impacted = Vec::new();
375 let mut visited = HashSet::new();
376 collect_impacted_nodes(node_id, &code_edges, graph, &mut visited, &mut impacted);
377
378 for impacted_node in impacted {
379 if seen.insert(impacted_node.id.clone()) {
380 let callers = count_callers(&impacted_node.id, &code_edges);
381 let callees = count_callees(&impacted_node.id, &code_edges);
382 let is_test = impacted_node.metadata.get("is_test")
383 .and_then(|v| v.as_bool()).unwrap_or(false);
384 let info = NodeInfo::from_node(impacted_node, callers, callees);
385
386 if is_test {
387 affected_tests.push(info);
388 } else {
389 affected_source.push(info);
390 }
391 }
392 }
393 }
394
395 let risk_level = match gid_ctx.max_callers {
397 0..=5 => RiskLevel::Low,
398 6..=20 => RiskLevel::Medium,
399 21..=50 => RiskLevel::High,
400 _ => RiskLevel::Critical,
401 };
402
403 let summary = format!(
405 "Changing {} file(s) affects {} source nodes and {} test nodes. Risk: {} (max {} callers, blast radius {}).",
406 files_changed.len(),
407 affected_source.len(),
408 affected_tests.len(),
409 risk_level,
410 gid_ctx.max_callers,
411 gid_ctx.total_blast_radius,
412 );
413
414 ImpactAnalysis {
415 affected_source,
416 affected_tests,
417 risk_level,
418 summary,
419 }
420}
421
422pub fn analyze_impact_filtered(
424 files_changed: &[String],
425 graph: &Graph,
426 relations: Option<&[&str]>,
427) -> ImpactAnalysis {
428 let gid_ctx = query_gid_context(files_changed, graph);
429 let code_edges = graph.code_edges();
430
431 let mut affected_source = Vec::new();
432 let mut affected_tests = Vec::new();
433 let mut seen = HashSet::new();
434
435 let changed_node_ids: Vec<String> = graph.code_nodes().iter()
436 .filter(|n| {
437 let fp = n.file_path.as_deref().unwrap_or("");
438 files_changed.iter().any(|f| f == fp)
439 })
440 .map(|n| n.id.clone())
441 .collect();
442
443 for node_id in &changed_node_ids {
444 let mut impacted = Vec::new();
445 let mut visited = HashSet::new();
446 collect_impacted_nodes_filtered(node_id, &code_edges, graph, relations, &mut visited, &mut impacted);
447
448 for impacted_node in impacted {
449 if seen.insert(impacted_node.id.clone()) {
450 let callers = count_callers(&impacted_node.id, &code_edges);
451 let callees = count_callees(&impacted_node.id, &code_edges);
452 let is_test = impacted_node.metadata.get("is_test")
453 .and_then(|v| v.as_bool()).unwrap_or(false);
454 let info = NodeInfo::from_node(impacted_node, callers, callees);
455
456 if is_test {
457 affected_tests.push(info);
458 } else {
459 affected_source.push(info);
460 }
461 }
462 }
463 }
464
465 let risk_level = match gid_ctx.max_callers {
466 0..=5 => RiskLevel::Low,
467 6..=20 => RiskLevel::Medium,
468 21..=50 => RiskLevel::High,
469 _ => RiskLevel::Critical,
470 };
471
472 let summary = format!(
473 "Changing {} file(s) affects {} source nodes and {} test nodes. Risk: {} (max {} callers, blast radius {}).",
474 files_changed.len(),
475 affected_source.len(),
476 affected_tests.len(),
477 risk_level,
478 gid_ctx.max_callers,
479 gid_ctx.total_blast_radius,
480 );
481
482 ImpactAnalysis {
483 affected_source,
484 affected_tests,
485 risk_level,
486 summary,
487 }
488}
489
490pub fn format_impact_for_llm(analysis: &ImpactAnalysis) -> String {
492 let mut result = String::new();
493
494 result.push_str(&format!("## Impact Analysis\n\n{}\n\n", analysis.summary));
495
496 if !analysis.affected_source.is_empty() {
497 result.push_str("**Affected source code:**\n");
498 for node in analysis.affected_source.iter().take(10) {
499 result.push_str(&format!(
500 "- {} `{}` ({} callers)\n",
501 node.kind, node.name, node.callers
502 ));
503 }
504 if analysis.affected_source.len() > 10 {
505 result.push_str(&format!(" ...and {} more\n", analysis.affected_source.len() - 10));
506 }
507 result.push('\n');
508 }
509
510 if !analysis.affected_tests.is_empty() {
511 result.push_str("**Related tests:**\n");
512 for node in analysis.affected_tests.iter().take(10) {
513 result.push_str(&format!("- `{}` in {}\n", node.name, node.file));
514 }
515 if analysis.affected_tests.len() > 10 {
516 result.push_str(&format!(" ...and {} more\n", analysis.affected_tests.len() - 10));
517 }
518 result.push('\n');
519 }
520
521 if analysis.risk_level == RiskLevel::High || analysis.risk_level == RiskLevel::Critical {
522 result.push_str("⚠️ **High-risk change!** Consider:\n");
523 result.push_str("- Breaking the change into smaller pieces\n");
524 result.push_str("- Adding backward compatibility\n");
525 result.push_str("- Running full test suite before committing\n\n");
526 }
527
528 result
529}
530
531#[derive(Debug, Clone)]
535pub enum Action {
536 Edit { files: Vec<String>, applied: usize, total: usize },
537 Revert,
538 Read { file: String },
539 Search { pattern: String },
540 Query { kind: String, target: String },
541 Test,
542 Other(String),
543}
544
545impl std::fmt::Display for Action {
546 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547 match self {
548 Action::Edit { files, applied, total } => {
549 let names: Vec<&str> = files.iter().map(|f| {
550 f.rsplit('/').next().unwrap_or(f.as_str())
551 }).collect();
552 write!(f, "EDIT {} ({}/{})", names.join(", "), applied, total)
553 }
554 Action::Revert => write!(f, "REVERT"),
555 Action::Read { file } => write!(f, "READ {}", file.rsplit('/').next().unwrap_or(file)),
556 Action::Search { pattern } => {
557 let display = if pattern.len() > 30 {
558 let mut end = 30;
559 while end > 0 && !pattern.is_char_boundary(end) { end -= 1; }
560 &pattern[..end]
561 } else {
562 pattern.as_str()
563 };
564 write!(f, "SEARCH '{}'", display)
565 }
566 Action::Query { kind, target } => write!(f, "GID {} {}", kind, target),
567 Action::Test => write!(f, "TEST"),
568 Action::Other(s) => {
569 let display = if s.len() > 30 {
570 let mut end = 30;
571 while end > 0 && !s.is_char_boundary(end) { end -= 1; }
572 &s[..end]
573 } else {
574 s.as_str()
575 };
576 write!(f, "{}", display)
577 }
578 }
579 }
580}
581
582#[derive(Debug, Clone)]
584pub struct TestOutcome {
585 pub error_type: ErrorType,
587 pub primary: (usize, usize),
589 pub secondary: (usize, usize),
591 pub key_error_trace: String,
593 pub failed_secondary_names: Vec<String>,
595}
596
597impl TestOutcome {
598 pub fn new(
599 error_type: ErrorType,
600 primary_passed: usize,
601 primary_total: usize,
602 secondary_passed: usize,
603 secondary_total: usize,
604 ) -> Self {
605 Self {
606 error_type,
607 primary: (primary_passed, primary_total),
608 secondary: (secondary_passed, secondary_total),
609 key_error_trace: String::new(),
610 failed_secondary_names: Vec::new(),
611 }
612 }
613
614 pub fn with_trace(mut self, trace: String) -> Self {
615 self.key_error_trace = trace;
616 self
617 }
618
619 pub fn with_failed_names(mut self, names: Vec<String>) -> Self {
620 self.failed_secondary_names = names;
621 self
622 }
623
624 pub fn score(&self) -> i32 {
627 let secondary_clean = if self.secondary.1 == 0 || self.secondary.0 == self.secondary.1 { 1 } else { 0 };
628 (self.primary.0 as i32) * 1000 * secondary_clean + self.secondary.0 as i32
629 }
630}
631
632#[derive(Debug, Clone)]
634pub struct AttemptRecord {
635 pub round: usize,
636 pub action: Action,
637 pub gid_context: Option<GidContext>,
638 pub test_outcome: Option<TestOutcome>,
639 pub feedback: String,
641}
642
643#[derive(Debug, Clone)]
645pub struct NodeRisk {
646 pub callers: usize,
647 pub times_tried: usize,
648 pub times_failed: usize,
649}
650
651pub struct WorkingMemory {
654 pub attempts: Vec<AttemptRecord>,
655 pub node_risk_map: std::collections::HashMap<String, NodeRisk>,
656 pub best_score: i32,
657 pub best_attempt: Option<usize>,
658 pub low_risk_alternatives: Vec<NodeInfo>,
660}
661
662impl Default for WorkingMemory {
663 fn default() -> Self {
664 Self::new()
665 }
666}
667
668impl WorkingMemory {
669 pub fn new() -> Self {
670 Self {
671 attempts: Vec::new(),
672 node_risk_map: std::collections::HashMap::new(),
673 best_score: -1,
674 best_attempt: None,
675 low_risk_alternatives: Vec::new(),
676 }
677 }
678
679 pub fn record_edit(
681 &mut self,
682 round: usize,
683 files: Vec<String>,
684 applied: usize,
685 total: usize,
686 gid_ctx: GidContext,
687 feedback: String,
688 ) {
689 self.attempts.push(AttemptRecord {
690 round,
691 action: Action::Edit { files, applied, total },
692 gid_context: Some(gid_ctx),
693 test_outcome: None,
694 feedback,
695 });
696 }
697
698 pub fn record_test(&mut self, round: usize, outcome: TestOutcome, raw_feedback: String) {
700 let score = outcome.score();
701
702 if score > self.best_score {
703 self.best_score = score;
704 self.best_attempt = Some(round);
705 }
706
707 if let Some(last_edit) = self.attempts.iter().rev().find(|a| matches!(a.action, Action::Edit { .. })) {
709 if let Some(ref gid) = last_edit.gid_context {
710 for node in &gid.nodes_touched {
711 let entry = self.node_risk_map.entry(node.name.clone()).or_insert(NodeRisk {
712 callers: node.callers,
713 times_tried: 0,
714 times_failed: 0,
715 });
716 entry.times_tried += 1;
717 if outcome.secondary.0 < outcome.secondary.1 || outcome.primary.0 < outcome.primary.1 {
718 entry.times_failed += 1;
719 }
720 }
721 }
722 }
723
724 self.attempts.push(AttemptRecord {
725 round,
726 action: Action::Test,
727 gid_context: None,
728 test_outcome: Some(outcome),
729 feedback: raw_feedback,
730 });
731 }
732
733 pub fn record_action(&mut self, round: usize, action: Action, feedback: String) {
735 self.attempts.push(AttemptRecord {
736 round,
737 action,
738 gid_context: None,
739 test_outcome: None,
740 feedback,
741 });
742 }
743
744 pub fn project_to_prompt(&self) -> String {
747 let mut out = String::new();
748
749 let test_attempts: Vec<&AttemptRecord> = self.attempts.iter()
751 .filter(|a| a.test_outcome.is_some())
752 .collect();
753
754 if !test_attempts.is_empty() {
755 out.push_str("## Attempt History\n\n");
756 out.push_str("| # | Target | Callers | Error | Primary | Secondary |\n");
757 out.push_str("|---|--------|---------|-------|---------|------------|\n");
758
759 for test_a in &test_attempts {
760 let t = test_a.test_outcome.as_ref().unwrap();
761
762 let edit_info = self.attempts.iter()
764 .filter(|a| a.round < test_a.round && matches!(a.action, Action::Edit { .. }))
765 .last();
766
767 let (target, callers) = if let Some(edit) = edit_info {
768 let target_str = match &edit.action {
769 Action::Edit { files, .. } => {
770 files.iter()
771 .map(|f| f.rsplit('/').next().unwrap_or(f))
772 .collect::<Vec<_>>()
773 .join(", ")
774 }
775 _ => "-".into(),
776 };
777 let callers_str = edit.gid_context.as_ref()
778 .map(|g| g.max_callers.to_string())
779 .unwrap_or("-".into());
780 (target_str, callers_str)
781 } else {
782 ("-".into(), "-".into())
783 };
784
785 out.push_str(&format!(
786 "| {} | {} | {} | {} | {}/{} | {}/{} |\n",
787 test_a.round,
788 target,
789 callers,
790 t.error_type,
791 t.primary.0, t.primary.1,
792 t.secondary.0, t.secondary.1,
793 ));
794 }
795 out.push('\n');
796 }
797
798 let mut risky: Vec<(&String, &NodeRisk)> = self.node_risk_map.iter()
800 .filter(|(_, r)| r.times_failed > 0)
801 .collect();
802 risky.sort_by(|a, b| b.1.callers.cmp(&a.1.callers));
803
804 if !risky.is_empty() {
805 out.push_str("## Node History\n");
806 for (name, risk) in risky.iter().take(10) {
807 out.push_str(&format!(
808 "- {} — {} callers, tried {}, failed {}\n",
809 name, risk.callers, risk.times_tried, risk.times_failed
810 ));
811 }
812 out.push('\n');
813 }
814
815 if !self.low_risk_alternatives.is_empty() {
817 out.push_str("## Low-Coupling Alternatives\n");
818 for alt in &self.low_risk_alternatives {
819 out.push_str(&format!(
820 "- {} ({}) — {} callers\n",
821 alt.name, alt.file.rsplit('/').next().unwrap_or(&alt.file), alt.callers
822 ));
823 }
824 out.push('\n');
825 }
826
827 if let Some(last_test) = self.attempts.iter().rev().find(|a| a.test_outcome.is_some()) {
829 let t = last_test.test_outcome.as_ref().unwrap();
830 out.push_str(&format!("## Latest Error (Round {})\n", last_test.round));
831 out.push_str(&format!("Type: {}\n", t.error_type));
832 out.push_str(&format!("Primary: {}/{}, Secondary: {}/{}\n",
833 t.primary.0, t.primary.1, t.secondary.0, t.secondary.1));
834
835 if !t.key_error_trace.is_empty() {
836 out.push_str(&format!("\n```\n{}\n```\n", t.key_error_trace));
837 }
838
839 if !t.failed_secondary_names.is_empty() {
841 let show: Vec<&str> = t.failed_secondary_names.iter().take(10).map(|s| s.as_str()).collect();
842 let remaining = t.failed_secondary_names.len().saturating_sub(10);
843 out.push_str(&format!("\nFailed: {}", show.join(", ")));
844 if remaining > 0 {
845 out.push_str(&format!(" (+{} more)", remaining));
846 }
847 out.push('\n');
848 }
849 }
850
851 if let Some(best_round) = self.best_attempt {
853 out.push_str(&format!(
854 "\n## Best Result: Round {} (score {})\n",
855 best_round, self.best_score
856 ));
857 }
858
859 out
860 }
861
862 pub fn last_feedback(&self) -> &str {
864 self.attempts.last()
865 .map(|a| a.feedback.as_str())
866 .unwrap_or("")
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use crate::graph::{Graph, Node, Edge, NodeStatus};
874
875 fn make_code_node(id: &str, title: &str, file_path: &str, kind: &str, line: Option<usize>, is_test: bool) -> Node {
877 let mut node = Node::new(id, title);
878 node.source = Some("extract".to_string());
879 node.node_type = Some("code".to_string());
880 node.status = NodeStatus::Done;
881 node.file_path = Some(file_path.to_string());
882 node.node_kind = Some(kind.to_string());
883 node.start_line = line;
884 if is_test {
885 node.metadata.insert("is_test".to_string(), serde_json::json!(true));
886 }
887 node
888 }
889
890 fn make_code_edge(from: &str, to: &str, relation: &str) -> Edge {
892 let mut edge = Edge::new(from, to, relation);
893 edge.metadata = Some(serde_json::json!({"source": "extract"}));
894 edge
895 }
896
897 #[test]
898 fn test_classify_error() {
899 assert_eq!(classify_error("SyntaxError: invalid syntax"), ErrorType::Syntax);
900 assert_eq!(classify_error("ImportError: No module named 'foo'"), ErrorType::Import);
901 assert_eq!(classify_error("AssertionError: 1 != 2"), ErrorType::Assertion);
902 }
903
904 #[test]
905 fn test_classify_syntax_overrides() {
906 let output = "ImportError: ...\nSyntaxError: invalid syntax\nImportError: ...";
907 assert_eq!(classify_error(output), ErrorType::Syntax);
908 }
909
910 #[test]
911 fn test_risk_level() {
912 let mut graph = Graph::new();
913
914 graph.add_node(make_code_node(
916 "func:core.py:hot_func", "hot_func", "core.py", "Function", Some(10), false,
917 ));
918
919 for i in 0..30 {
921 let caller_id = format!("func:caller{}.py:caller_{}", i, i);
922 graph.add_node(make_code_node(
923 &caller_id, &format!("caller_{}", i), &format!("caller{}.py", i), "Function", Some(1), false,
924 ));
925 graph.add_edge(make_code_edge(&caller_id, "func:core.py:hot_func", "calls"));
926 }
927
928 let analysis = analyze_impact(&["core.py".into()], &graph);
929 assert_eq!(analysis.risk_level, RiskLevel::High);
930 }
931
932 #[test]
933 fn test_extract_traceback() {
934 let output = r#"
935FAILED tests/test_foo.py::test_bar
936Traceback (most recent call last):
937 File "tests/test_foo.py", line 10, in test_bar
938 assert result == expected
939AssertionError: 1 != 2
940
941FAILED tests/test_other.py::test_baz
942"#;
943 let tb = extract_key_traceback(output, 500);
944 assert!(tb.contains("Traceback (most recent call last)"));
945 assert!(tb.contains("AssertionError: 1 != 2"));
946 }
947}