Skip to main content

cqs/
task.rs

1//! Task — one-shot implementation context for a task description.
2//!
3//! Combines scout + gather + impact + placement + notes into a single call,
4//! loading shared resources (call graph, test chunks) once instead of per-phase.
5
6use 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
18/// BFS expansion depth for gather phase (how many call-graph hops from modify targets).
19const TASK_GATHER_DEPTH: usize = 2;
20
21/// Maximum BFS-expanded nodes in gather phase (prevents blowup on hub functions).
22const TASK_GATHER_MAX_NODES: usize = 100;
23
24/// Multiplier applied to `limit` for gather phase truncation.
25const TASK_GATHER_LIMIT_MULTIPLIER: usize = 3;
26
27/// Per-function risk assessment from impact analysis.
28#[derive(Debug, Clone, serde::Serialize)]
29pub struct FunctionRisk {
30    /// Function name.
31    pub name: String,
32    /// Risk score and level.
33    pub risk: RiskScore,
34}
35
36/// Complete task analysis result.
37#[derive(Debug, Clone, serde::Serialize)]
38pub struct TaskResult {
39    /// Original task description.
40    pub description: String,
41    /// Scout phase: file groups, chunk roles, staleness, notes.
42    pub scout: ScoutResult,
43    /// Gather phase: BFS-expanded code with full content.
44    pub code: Vec<GatheredChunk>,
45    /// Impact phase: per-modify-target risk assessment.
46    pub risk: Vec<FunctionRisk>,
47    /// Impact phase: affected tests (deduped across targets).
48    pub tests: Vec<TestInfo>,
49    /// Placement phase: where to add new code.
50    pub placement: Vec<FileSuggestion>,
51    /// Aggregated summary counts.
52    pub summary: TaskSummary,
53}
54
55/// Summary statistics for a task result.
56#[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
66/// Produce complete implementation context for a task description.
67///
68/// Loads the call graph and test chunks once, then runs scout → gather → impact →
69/// placement in sequence, sharing resources across phases.
70pub 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
96/// Like [`task`] but accepts pre-loaded call graph and test chunks.
97///
98/// Use this in batch mode where `BatchContext` caches these resources across
99/// commands, avoiding repeated loading per pipeline stage.
100pub 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    // 1. Embed query
112    let query_embedding = embedder.embed_query(description)?;
113
114    // 2. Scout phase
115    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    // 4. Gather phase — expand modify targets via BFS
132    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    // 5. Impact phase — risk scores + affected tests (single BFS per target)
159    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    // 6. Placement phase — reuse query embedding to avoid redundant ONNX inference
177    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    // 7. Assemble result
196    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
217/// Extract modify target names from scout results.
218pub 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
228/// Compute summary statistics from task phases.
229pub(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
256/// Serialize task result to JSON.
257///
258/// Uses manual construction since ScoutResult doesn't derive Serialize.
259/// Reuses `scout_to_json()` for the scout section.
260pub 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        // Notes are in scout.relevant_notes, no top-level "notes" key
470        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    // TC-3: task_to_json with populated code/risk/tests/placement
513    #[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        // Verify code section values
581        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        // Verify risk section values
587        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        // Verify tests section values
594        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        // Verify placement section values
600        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}