1use std::collections::HashMap;
7use std::path::Path;
8
9use crate::gather::{
10 bfs_expand, fetch_and_assemble, sort_and_truncate, GatherDirection, GatherOptions,
11 GatheredChunk,
12};
13use crate::impact::{compute_risk_and_tests, RiskLevel, RiskScore, TestInfo};
14use crate::scout::{scout_core, ChunkRole, ScoutOptions, ScoutResult};
15use crate::where_to_add::FileSuggestion;
16use crate::{AnalysisError, Embedder, Store};
17
18const TASK_GATHER_DEPTH: usize = 2;
20
21const TASK_GATHER_MAX_NODES: usize = 100;
23
24const TASK_GATHER_LIMIT_MULTIPLIER: usize = 3;
26
27#[derive(Debug, Clone, serde::Serialize)]
29pub struct FunctionRisk {
30 pub name: String,
32 pub risk: RiskScore,
34}
35
36#[derive(Debug, Clone, serde::Serialize)]
38pub struct TaskResult {
39 pub description: String,
41 pub scout: ScoutResult,
43 pub code: Vec<GatheredChunk>,
45 pub risk: Vec<FunctionRisk>,
47 pub tests: Vec<TestInfo>,
49 pub placement: Vec<FileSuggestion>,
51 pub summary: TaskSummary,
53}
54
55#[derive(Debug, Clone, serde::Serialize)]
57pub struct TaskSummary {
58 pub total_files: usize,
59 pub total_functions: usize,
60 pub modify_targets: usize,
61 pub high_risk_count: usize,
62 pub test_count: usize,
63 pub stale_count: usize,
64}
65
66pub fn task(
71 store: &Store,
72 embedder: &Embedder,
73 description: &str,
74 root: &Path,
75 limit: usize,
76) -> Result<TaskResult, AnalysisError> {
77 let graph = store.get_call_graph()?;
78 let test_chunks = match store.find_test_chunks() {
79 Ok(tc) => tc,
80 Err(e) => {
81 tracing::warn!(error = %e, "Test chunk loading failed, continuing without tests");
82 Vec::new()
83 }
84 };
85 task_with_resources(
86 store,
87 embedder,
88 description,
89 root,
90 limit,
91 &graph,
92 &test_chunks,
93 )
94}
95
96pub fn task_with_resources(
101 store: &Store,
102 embedder: &Embedder,
103 description: &str,
104 root: &Path,
105 limit: usize,
106 graph: &crate::store::CallGraph,
107 test_chunks: &[crate::store::ChunkSummary],
108) -> Result<TaskResult, AnalysisError> {
109 let _span = tracing::info_span!("task", description_len = description.len(), limit).entered();
110
111 let query_embedding = embedder.embed_query(description)?;
113
114 let scout = scout_core(
116 store,
117 &query_embedding,
118 description,
119 root,
120 limit,
121 &ScoutOptions::default(),
122 graph,
123 test_chunks,
124 )?;
125 tracing::debug!(
126 file_groups = scout.file_groups.len(),
127 functions = scout.summary.total_functions,
128 "Scout complete"
129 );
130
131 let targets = extract_modify_targets(&scout);
133 let code = if targets.is_empty() {
134 Vec::new()
135 } else {
136 let mut name_scores: HashMap<String, (f32, usize)> =
137 targets.iter().map(|n| (n.to_string(), (1.0, 0))).collect();
138
139 bfs_expand(
140 &mut name_scores,
141 graph,
142 &GatherOptions::default()
143 .with_expand_depth(TASK_GATHER_DEPTH)
144 .with_direction(GatherDirection::Both)
145 .with_max_expanded_nodes(TASK_GATHER_MAX_NODES),
146 );
147
148 let (mut chunks, _degraded) = fetch_and_assemble(store, &name_scores, root);
149 sort_and_truncate(&mut chunks, limit * TASK_GATHER_LIMIT_MULTIPLIER);
150 chunks
151 };
152 tracing::debug!(
153 targets = targets.len(),
154 expanded = code.len(),
155 "Gather complete"
156 );
157
158 let (risk, tests) = if targets.is_empty() {
160 (Vec::new(), Vec::new())
161 } else {
162 let target_refs: Vec<&str> = targets.iter().map(|s| s.as_str()).collect();
163 let (scores, tests) = compute_risk_and_tests(&target_refs, graph, test_chunks);
164 let risk = target_refs
165 .iter()
166 .zip(scores)
167 .map(|(&n, r)| FunctionRisk {
168 name: n.to_string(),
169 risk: r,
170 })
171 .collect();
172 (risk, tests)
173 };
174 tracing::debug!(risks = risk.len(), tests = tests.len(), "Impact complete");
175
176 let placement_opts = crate::where_to_add::PlacementOptions {
178 query_embedding: Some(query_embedding.clone()),
179 ..Default::default()
180 };
181 let placement = match crate::where_to_add::suggest_placement_with_options(
182 store,
183 embedder,
184 description,
185 3,
186 &placement_opts,
187 ) {
188 Ok(result) => result.suggestions,
189 Err(e) => {
190 tracing::warn!(error = %e, "Placement suggestion failed, skipping");
191 Vec::new()
192 }
193 };
194
195 let summary = compute_summary(&scout, &risk, &tests);
197 tracing::info!(
198 files = summary.total_files,
199 functions = summary.total_functions,
200 targets = summary.modify_targets,
201 high_risk = summary.high_risk_count,
202 tests = summary.test_count,
203 "Task complete"
204 );
205
206 Ok(TaskResult {
207 description: description.to_string(),
208 scout,
209 code,
210 risk,
211 tests,
212 placement,
213 summary,
214 })
215}
216
217pub fn extract_modify_targets(scout: &ScoutResult) -> Vec<String> {
219 scout
220 .file_groups
221 .iter()
222 .flat_map(|g| &g.chunks)
223 .filter(|c| c.role == ChunkRole::ModifyTarget)
224 .map(|c| c.name.clone())
225 .collect()
226}
227
228pub(crate) fn compute_summary(
230 scout: &ScoutResult,
231 risk: &[FunctionRisk],
232 tests: &[TestInfo],
233) -> TaskSummary {
234 let modify_targets = scout
235 .file_groups
236 .iter()
237 .flat_map(|g| &g.chunks)
238 .filter(|c| c.role == ChunkRole::ModifyTarget)
239 .count();
240
241 let high_risk_count = risk
242 .iter()
243 .filter(|fr| fr.risk.risk_level == RiskLevel::High)
244 .count();
245
246 TaskSummary {
247 total_files: scout.summary.total_files,
248 total_functions: scout.summary.total_functions,
249 modify_targets,
250 high_risk_count,
251 test_count: tests.len(),
252 stale_count: scout.summary.stale_count,
253 }
254}
255
256pub fn task_to_json(result: &TaskResult, root: &Path) -> serde_json::Value {
261 let scout_json = crate::scout::scout_to_json(&result.scout, root);
262
263 let code_json: Vec<serde_json::Value> = result
264 .code
265 .iter()
266 .filter_map(|c| match serde_json::to_value(c) {
267 Ok(v) => Some(v),
268 Err(e) => {
269 tracing::warn!(error = %e, chunk = %c.name, "Failed to serialize chunk");
270 None
271 }
272 })
273 .collect();
274 let risk_json: Vec<serde_json::Value> = result
275 .risk
276 .iter()
277 .map(|fr| fr.risk.to_json(&fr.name))
278 .collect();
279 let tests_json: Vec<serde_json::Value> = result.tests.iter().map(|t| t.to_json(root)).collect();
280 let placement_json: Vec<serde_json::Value> =
281 result.placement.iter().map(|s| s.to_json(root)).collect();
282
283 serde_json::json!({
284 "description": result.description,
285 "scout": scout_json,
286 "code": code_json,
287 "risk": risk_json,
288 "tests": tests_json,
289 "placement": placement_json,
290 "summary": {
291 "total_files": result.summary.total_files,
292 "total_functions": result.summary.total_functions,
293 "modify_targets": result.summary.modify_targets,
294 "high_risk_count": result.summary.high_risk_count,
295 "test_count": result.summary.test_count,
296 "stale_count": result.summary.stale_count,
297 }
298 })
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::scout::{FileGroup, ScoutChunk, ScoutSummary};
305 use crate::store::NoteSummary;
306 use std::path::PathBuf;
307
308 fn make_scout_chunk(name: &str, role: ChunkRole) -> ScoutChunk {
309 ScoutChunk {
310 name: name.to_string(),
311 chunk_type: crate::language::ChunkType::Function,
312 signature: format!("fn {name}()"),
313 line_start: 1,
314 role,
315 caller_count: 3,
316 test_count: 1,
317 search_score: 0.8,
318 }
319 }
320
321 fn make_scout_result(chunks: Vec<(&str, ChunkRole)>) -> ScoutResult {
322 let scout_chunks: Vec<ScoutChunk> = chunks
323 .iter()
324 .map(|(name, role)| make_scout_chunk(name, role.clone()))
325 .collect();
326 let total_functions = scout_chunks.len();
327
328 ScoutResult {
329 file_groups: vec![FileGroup {
330 file: PathBuf::from("src/lib.rs"),
331 relevance_score: 0.7,
332 chunks: scout_chunks,
333 is_stale: false,
334 }],
335 relevant_notes: vec![NoteSummary {
336 id: "1".to_string(),
337 text: "test note".to_string(),
338 sentiment: 0.5,
339 mentions: vec!["src/lib.rs".to_string()],
340 }],
341 summary: ScoutSummary {
342 total_files: 1,
343 total_functions,
344 untested_count: 0,
345 stale_count: 0,
346 },
347 }
348 }
349
350 #[test]
351 fn test_extract_modify_targets() {
352 let scout = make_scout_result(vec![
353 ("target_fn", ChunkRole::ModifyTarget),
354 ("test_fn", ChunkRole::TestToUpdate),
355 ("dep_fn", ChunkRole::Dependency),
356 ("target2", ChunkRole::ModifyTarget),
357 ]);
358 let targets = extract_modify_targets(&scout);
359 assert_eq!(targets, vec!["target_fn", "target2"]);
360 }
361
362 #[test]
363 fn test_extract_modify_targets_empty() {
364 let scout = make_scout_result(vec![
365 ("test_fn", ChunkRole::TestToUpdate),
366 ("dep_fn", ChunkRole::Dependency),
367 ]);
368 let targets = extract_modify_targets(&scout);
369 assert!(targets.is_empty());
370 }
371
372 #[test]
373 fn test_summary_computation() {
374 let scout = make_scout_result(vec![
375 ("a", ChunkRole::ModifyTarget),
376 ("b", ChunkRole::ModifyTarget),
377 ("c", ChunkRole::Dependency),
378 ]);
379
380 let risk = vec![
381 FunctionRisk {
382 name: "a".to_string(),
383 risk: RiskScore {
384 caller_count: 5,
385 test_count: 0,
386 coverage: 0.0,
387 risk_level: RiskLevel::High,
388 blast_radius: RiskLevel::Medium,
389 score: 5.0,
390 },
391 },
392 FunctionRisk {
393 name: "b".to_string(),
394 risk: RiskScore {
395 caller_count: 2,
396 test_count: 2,
397 coverage: 1.0,
398 risk_level: RiskLevel::Low,
399 blast_radius: RiskLevel::Low,
400 score: 0.0,
401 },
402 },
403 ];
404
405 let tests = vec![TestInfo {
406 name: "test_a".to_string(),
407 file: PathBuf::from("tests/a.rs"),
408 line: 10,
409 call_depth: 1,
410 }];
411
412 let summary = compute_summary(&scout, &risk, &tests);
413 assert_eq!(summary.total_files, 1);
414 assert_eq!(summary.total_functions, 3);
415 assert_eq!(summary.modify_targets, 2);
416 assert_eq!(summary.high_risk_count, 1);
417 assert_eq!(summary.test_count, 1);
418 assert_eq!(summary.stale_count, 0);
419 }
420
421 #[test]
422 fn test_summary_empty() {
423 let scout = ScoutResult {
424 file_groups: Vec::new(),
425 relevant_notes: Vec::new(),
426 summary: ScoutSummary {
427 total_files: 0,
428 total_functions: 0,
429 untested_count: 0,
430 stale_count: 0,
431 },
432 };
433 let summary = compute_summary(&scout, &[], &[]);
434 assert_eq!(summary.total_files, 0);
435 assert_eq!(summary.total_functions, 0);
436 assert_eq!(summary.modify_targets, 0);
437 assert_eq!(summary.high_risk_count, 0);
438 assert_eq!(summary.test_count, 0);
439 assert_eq!(summary.stale_count, 0);
440 }
441
442 #[test]
443 fn test_task_to_json_structure() {
444 let scout = make_scout_result(vec![("fn_a", ChunkRole::ModifyTarget)]);
445 let result = TaskResult {
446 description: "test task".to_string(),
447 scout,
448 code: Vec::new(),
449 risk: Vec::new(),
450 tests: Vec::new(),
451 placement: Vec::new(),
452 summary: TaskSummary {
453 total_files: 1,
454 total_functions: 1,
455 modify_targets: 1,
456 high_risk_count: 0,
457 test_count: 0,
458 stale_count: 0,
459 },
460 };
461
462 let json = task_to_json(&result, Path::new("/project"));
463 assert_eq!(json["description"], "test task");
464 assert!(json["scout"].is_object());
465 assert!(json["code"].is_array());
466 assert!(json["risk"].is_array());
467 assert!(json["tests"].is_array());
468 assert!(json["placement"].is_array());
469 assert!(json["scout"]["relevant_notes"].is_array());
471 assert!(json["summary"].is_object());
472 assert_eq!(json["summary"]["modify_targets"], 1);
473 }
474
475 #[test]
476 fn test_task_to_json_empty() {
477 let result = TaskResult {
478 description: "empty".to_string(),
479 scout: ScoutResult {
480 file_groups: Vec::new(),
481 relevant_notes: Vec::new(),
482 summary: ScoutSummary {
483 total_files: 0,
484 total_functions: 0,
485 untested_count: 0,
486 stale_count: 0,
487 },
488 },
489 code: Vec::new(),
490 risk: Vec::new(),
491 tests: Vec::new(),
492 placement: Vec::new(),
493 summary: TaskSummary {
494 total_files: 0,
495 total_functions: 0,
496 modify_targets: 0,
497 high_risk_count: 0,
498 test_count: 0,
499 stale_count: 0,
500 },
501 };
502
503 let json = task_to_json(&result, Path::new("/project"));
504 assert_eq!(json["code"].as_array().unwrap().len(), 0);
505 assert_eq!(json["risk"].as_array().unwrap().len(), 0);
506 assert_eq!(json["tests"].as_array().unwrap().len(), 0);
507 assert_eq!(json["placement"].as_array().unwrap().len(), 0);
508 assert_eq!(json["scout"]["relevant_notes"].as_array().unwrap().len(), 0);
509 assert_eq!(json["summary"]["total_files"], 0);
510 }
511
512 #[test]
514 fn test_task_to_json_populated_values() {
515 use crate::gather::GatheredChunk;
516 use crate::impact::TestInfo;
517 use crate::language::{ChunkType, Language};
518 use crate::where_to_add::{FileSuggestion, LocalPatterns};
519
520 let scout = make_scout_result(vec![("fn_a", ChunkRole::ModifyTarget)]);
521 let result = TaskResult {
522 description: "add caching".to_string(),
523 scout,
524 code: vec![GatheredChunk {
525 name: "fn_a".to_string(),
526 file: PathBuf::from("/project/src/lib.rs"),
527 line_start: 10,
528 line_end: 20,
529 language: Language::Rust,
530 chunk_type: ChunkType::Function,
531 signature: "fn fn_a()".to_string(),
532 content: "fn fn_a() { }".to_string(),
533 score: 0.9,
534 depth: 0,
535 source: None,
536 }],
537 risk: vec![FunctionRisk {
538 name: "fn_a".to_string(),
539 risk: RiskScore {
540 caller_count: 5,
541 test_count: 1,
542 coverage: 0.2,
543 risk_level: RiskLevel::High,
544 blast_radius: RiskLevel::Medium,
545 score: 4.0,
546 },
547 }],
548 tests: vec![TestInfo {
549 name: "test_fn_a".to_string(),
550 file: PathBuf::from("/project/tests/a.rs"),
551 line: 5,
552 call_depth: 1,
553 }],
554 placement: vec![FileSuggestion {
555 file: PathBuf::from("/project/src/lib.rs"),
556 score: 0.85,
557 insertion_line: 25,
558 near_function: "fn_a".to_string(),
559 reason: "same module".to_string(),
560 patterns: LocalPatterns {
561 imports: vec!["use std::path::Path;".to_string()],
562 naming_convention: "snake_case".to_string(),
563 error_handling: "Result".to_string(),
564 visibility: "pub".to_string(),
565 has_inline_tests: true,
566 },
567 }],
568 summary: TaskSummary {
569 total_files: 1,
570 total_functions: 1,
571 modify_targets: 1,
572 high_risk_count: 1,
573 test_count: 1,
574 stale_count: 0,
575 },
576 };
577
578 let json = task_to_json(&result, Path::new("/project"));
579
580 let code = json["code"].as_array().unwrap();
582 assert_eq!(code.len(), 1);
583 assert_eq!(code[0]["name"], "fn_a");
584 assert_eq!(code[0]["signature"], "fn fn_a()");
585
586 let risk = json["risk"].as_array().unwrap();
588 assert_eq!(risk.len(), 1);
589 assert_eq!(risk[0]["name"], "fn_a");
590 assert_eq!(risk[0]["risk_level"], "high");
591 assert_eq!(risk[0]["caller_count"], 5);
592
593 let tests = json["tests"].as_array().unwrap();
595 assert_eq!(tests.len(), 1);
596 assert_eq!(tests[0]["name"], "test_fn_a");
597 assert_eq!(tests[0]["call_depth"], 1);
598
599 let placement = json["placement"].as_array().unwrap();
601 assert_eq!(placement.len(), 1);
602 assert_eq!(placement[0]["near_function"], "fn_a");
603 assert_eq!(placement[0]["reason"], "same module");
604 }
605}