use std::collections::HashMap;
use std::path::Path;
use crate::gather::{
bfs_expand, fetch_and_assemble, sort_and_truncate, GatherDirection, GatherOptions,
GatheredChunk,
};
use crate::impact::{compute_risk_and_tests, RiskLevel, RiskScore, TestInfo};
use crate::scout::{scout_core, ChunkRole, ScoutOptions, ScoutResources, ScoutResult};
use crate::where_to_add::FileSuggestion;
use crate::{AnalysisError, Embedder, Store};
const TASK_GATHER_DEPTH: usize = 2;
const TASK_GATHER_MAX_NODES: usize = 100;
const TASK_GATHER_LIMIT_MULTIPLIER: usize = 3;
#[derive(Debug, Clone, serde::Serialize)]
pub struct FunctionRisk {
pub name: String,
pub risk: RiskScore,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TaskResult {
pub description: String,
pub scout: ScoutResult,
pub code: Vec<GatheredChunk>,
pub risk: Vec<FunctionRisk>,
pub tests: Vec<TestInfo>,
pub placement: Vec<FileSuggestion>,
pub summary: TaskSummary,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TaskSummary {
pub total_files: usize,
pub total_functions: usize,
pub modify_targets: usize,
pub high_risk_count: usize,
pub test_count: usize,
pub stale_count: usize,
}
pub fn task(
store: &Store,
embedder: &Embedder,
description: &str,
root: &Path,
limit: usize,
) -> Result<TaskResult, AnalysisError> {
let graph = store.get_call_graph()?;
let test_chunks = match store.find_test_chunks() {
Ok(tc) => tc,
Err(e) => {
tracing::warn!(error = %e, "Test chunk loading failed, continuing without tests");
std::sync::Arc::new(Vec::new())
}
};
task_with_resources(
store,
embedder,
description,
root,
limit,
&graph,
&test_chunks,
)
}
pub fn task_with_resources(
store: &Store,
embedder: &Embedder,
description: &str,
root: &Path,
limit: usize,
graph: &crate::store::CallGraph,
test_chunks: &[crate::store::ChunkSummary],
) -> Result<TaskResult, AnalysisError> {
let _span = tracing::info_span!("task", description_len = description.len(), limit).entered();
let query_embedding = embedder.embed_query(description)?;
let scout = scout_core(&ScoutResources {
store,
query_embedding: &query_embedding,
task: description,
root,
limit,
opts: &ScoutOptions::default(),
graph,
test_chunks,
})?;
tracing::debug!(
file_groups = scout.file_groups.len(),
functions = scout.summary.total_functions,
"Scout complete"
);
let targets = extract_modify_targets(&scout);
let code = if targets.is_empty() {
Vec::new()
} else {
let mut name_scores: HashMap<String, (f32, usize)> =
targets.iter().map(|n| (n.to_string(), (1.0, 0))).collect();
bfs_expand(
&mut name_scores,
graph,
&GatherOptions::default()
.with_expand_depth(TASK_GATHER_DEPTH)
.with_direction(GatherDirection::Both)
.with_max_expanded_nodes(TASK_GATHER_MAX_NODES),
);
let (mut chunks, _degraded) = fetch_and_assemble(store, &name_scores, root);
sort_and_truncate(&mut chunks, limit * TASK_GATHER_LIMIT_MULTIPLIER);
chunks
};
tracing::debug!(
targets = targets.len(),
expanded = code.len(),
"Gather complete"
);
let (risk, tests) = if targets.is_empty() {
(Vec::new(), Vec::new())
} else {
let target_refs: Vec<&str> = targets.iter().map(|s| s.as_str()).collect();
let (scores, raw_tests) = compute_risk_and_tests(&target_refs, graph, test_chunks);
let risk = target_refs
.iter()
.zip(scores)
.map(|(&n, r)| FunctionRisk {
name: n.to_string(),
risk: r,
})
.collect();
let tests = raw_tests
.into_iter()
.map(|t| crate::impact::TestInfo {
file: t.file.strip_prefix(root).unwrap_or(&t.file).to_path_buf(),
..t
})
.collect();
(risk, tests)
};
tracing::debug!(risks = risk.len(), tests = tests.len(), "Impact complete");
let placement_opts = crate::where_to_add::PlacementOptions {
query_embedding: Some(query_embedding.clone()),
..Default::default()
};
let placement: Vec<_> = match crate::where_to_add::suggest_placement_with_options(
store,
embedder,
description,
3,
&placement_opts,
) {
Ok(result) => result
.suggestions
.into_iter()
.map(|mut s| {
s.file = s.file.strip_prefix(root).unwrap_or(&s.file).to_path_buf();
s
})
.collect(),
Err(e) => {
tracing::warn!(error = %e, "Placement suggestion failed, skipping");
Vec::new()
}
};
let summary = compute_summary(&scout, &risk, &tests);
tracing::info!(
files = summary.total_files,
functions = summary.total_functions,
targets = summary.modify_targets,
high_risk = summary.high_risk_count,
tests = summary.test_count,
"Task complete"
);
Ok(TaskResult {
description: description.to_string(),
scout,
code,
risk,
tests,
placement,
summary,
})
}
pub fn extract_modify_targets(scout: &ScoutResult) -> Vec<String> {
scout
.file_groups
.iter()
.flat_map(|g| &g.chunks)
.filter(|c| c.role == ChunkRole::ModifyTarget)
.map(|c| c.name.clone())
.collect()
}
pub(crate) fn compute_summary(
scout: &ScoutResult,
risk: &[FunctionRisk],
tests: &[TestInfo],
) -> TaskSummary {
let modify_targets = scout
.file_groups
.iter()
.flat_map(|g| &g.chunks)
.filter(|c| c.role == ChunkRole::ModifyTarget)
.count();
let high_risk_count = risk
.iter()
.filter(|fr| fr.risk.risk_level == RiskLevel::High)
.count();
TaskSummary {
total_files: scout.summary.total_files,
total_functions: scout.summary.total_functions,
modify_targets,
high_risk_count,
test_count: tests.len(),
stale_count: scout.summary.stale_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scout::{FileGroup, ScoutChunk, ScoutSummary};
use crate::store::NoteSummary;
use std::path::PathBuf;
fn make_scout_chunk(name: &str, role: ChunkRole) -> ScoutChunk {
ScoutChunk {
name: name.to_string(),
chunk_type: crate::language::ChunkType::Function,
signature: format!("fn {name}()"),
line_start: 1,
role,
caller_count: 3,
test_count: 1,
search_score: 0.8,
}
}
fn make_scout_result(chunks: Vec<(&str, ChunkRole)>) -> ScoutResult {
let scout_chunks: Vec<ScoutChunk> = chunks
.iter()
.map(|(name, role)| make_scout_chunk(name, role.clone()))
.collect();
let total_functions = scout_chunks.len();
ScoutResult {
file_groups: vec![FileGroup {
file: PathBuf::from("src/lib.rs"),
relevance_score: 0.7,
chunks: scout_chunks,
is_stale: false,
}],
relevant_notes: vec![NoteSummary {
id: "1".to_string(),
text: "test note".to_string(),
sentiment: 0.5,
mentions: vec!["src/lib.rs".to_string()],
}],
summary: ScoutSummary {
total_files: 1,
total_functions,
untested_count: 0,
stale_count: 0,
},
}
}
#[test]
fn test_extract_modify_targets() {
let scout = make_scout_result(vec![
("target_fn", ChunkRole::ModifyTarget),
("test_fn", ChunkRole::TestToUpdate),
("dep_fn", ChunkRole::Dependency),
("target2", ChunkRole::ModifyTarget),
]);
let targets = extract_modify_targets(&scout);
assert_eq!(targets, vec!["target_fn", "target2"]);
}
#[test]
fn test_extract_modify_targets_empty() {
let scout = make_scout_result(vec![
("test_fn", ChunkRole::TestToUpdate),
("dep_fn", ChunkRole::Dependency),
]);
let targets = extract_modify_targets(&scout);
assert!(targets.is_empty());
}
#[test]
fn test_summary_computation() {
let scout = make_scout_result(vec![
("a", ChunkRole::ModifyTarget),
("b", ChunkRole::ModifyTarget),
("c", ChunkRole::Dependency),
]);
let risk = vec![
FunctionRisk {
name: "a".to_string(),
risk: RiskScore {
caller_count: 5,
test_count: 0,
test_ratio: 0.0,
risk_level: RiskLevel::High,
blast_radius: RiskLevel::Medium,
score: 5.0,
},
},
FunctionRisk {
name: "b".to_string(),
risk: RiskScore {
caller_count: 2,
test_count: 2,
test_ratio: 1.0,
risk_level: RiskLevel::Low,
blast_radius: RiskLevel::Low,
score: 0.0,
},
},
];
let tests = vec![TestInfo {
name: "test_a".to_string(),
file: PathBuf::from("tests/a.rs"),
line: 10,
call_depth: 1,
}];
let summary = compute_summary(&scout, &risk, &tests);
assert_eq!(summary.total_files, 1);
assert_eq!(summary.total_functions, 3);
assert_eq!(summary.modify_targets, 2);
assert_eq!(summary.high_risk_count, 1);
assert_eq!(summary.test_count, 1);
assert_eq!(summary.stale_count, 0);
}
#[test]
fn test_summary_empty() {
let scout = ScoutResult {
file_groups: Vec::new(),
relevant_notes: Vec::new(),
summary: ScoutSummary {
total_files: 0,
total_functions: 0,
untested_count: 0,
stale_count: 0,
},
};
let summary = compute_summary(&scout, &[], &[]);
assert_eq!(summary.total_files, 0);
assert_eq!(summary.total_functions, 0);
assert_eq!(summary.modify_targets, 0);
assert_eq!(summary.high_risk_count, 0);
assert_eq!(summary.test_count, 0);
assert_eq!(summary.stale_count, 0);
}
#[test]
fn test_task_result_serialization_structure() {
let scout = make_scout_result(vec![("fn_a", ChunkRole::ModifyTarget)]);
let result = TaskResult {
description: "test task".to_string(),
scout,
code: Vec::new(),
risk: Vec::new(),
tests: Vec::new(),
placement: Vec::new(),
summary: TaskSummary {
total_files: 1,
total_functions: 1,
modify_targets: 1,
high_risk_count: 0,
test_count: 0,
stale_count: 0,
},
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["description"], "test task");
assert!(json["scout"].is_object());
assert!(json["code"].is_array());
assert!(json["risk"].is_array());
assert!(json["tests"].is_array());
assert!(json["placement"].is_array());
assert!(json["scout"]["relevant_notes"].is_array());
assert!(json["summary"].is_object());
assert_eq!(json["summary"]["modify_targets"], 1);
}
#[test]
fn test_task_result_serialization_empty() {
let result = TaskResult {
description: "empty".to_string(),
scout: ScoutResult {
file_groups: Vec::new(),
relevant_notes: Vec::new(),
summary: ScoutSummary {
total_files: 0,
total_functions: 0,
untested_count: 0,
stale_count: 0,
},
},
code: Vec::new(),
risk: Vec::new(),
tests: Vec::new(),
placement: Vec::new(),
summary: TaskSummary {
total_files: 0,
total_functions: 0,
modify_targets: 0,
high_risk_count: 0,
test_count: 0,
stale_count: 0,
},
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["code"].as_array().unwrap().len(), 0);
assert_eq!(json["risk"].as_array().unwrap().len(), 0);
assert_eq!(json["tests"].as_array().unwrap().len(), 0);
assert_eq!(json["placement"].as_array().unwrap().len(), 0);
assert_eq!(json["scout"]["relevant_notes"].as_array().unwrap().len(), 0);
assert_eq!(json["summary"]["total_files"], 0);
}
#[test]
fn test_task_result_serialization_populated_values() {
use crate::gather::GatheredChunk;
use crate::impact::TestInfo;
use crate::language::{ChunkType, Language};
use crate::where_to_add::{FileSuggestion, LocalPatterns};
let scout = make_scout_result(vec![("fn_a", ChunkRole::ModifyTarget)]);
let result = TaskResult {
description: "add caching".to_string(),
scout,
code: vec![GatheredChunk {
name: "fn_a".to_string(),
file: PathBuf::from("src/lib.rs"),
line_start: 10,
line_end: 20,
language: Language::Rust,
chunk_type: ChunkType::Function,
signature: "fn fn_a()".to_string(),
content: "fn fn_a() { }".to_string(),
score: 0.9,
depth: 0,
source: None,
}],
risk: vec![FunctionRisk {
name: "fn_a".to_string(),
risk: RiskScore {
caller_count: 5,
test_count: 1,
test_ratio: 0.2,
risk_level: RiskLevel::High,
blast_radius: RiskLevel::Medium,
score: 4.0,
},
}],
tests: vec![TestInfo {
name: "test_fn_a".to_string(),
file: PathBuf::from("tests/a.rs"),
line: 5,
call_depth: 1,
}],
placement: vec![FileSuggestion {
file: PathBuf::from("src/lib.rs"),
score: 0.85,
insertion_line: 25,
near_function: "fn_a".to_string(),
reason: "same module".to_string(),
patterns: LocalPatterns {
imports: vec!["use std::path::Path;".to_string()],
naming_convention: "snake_case".to_string(),
error_handling: "Result".to_string(),
visibility: "pub".to_string(),
has_inline_tests: true,
},
}],
summary: TaskSummary {
total_files: 1,
total_functions: 1,
modify_targets: 1,
high_risk_count: 1,
test_count: 1,
stale_count: 0,
},
};
let json = serde_json::to_value(&result).unwrap();
let code = json["code"].as_array().unwrap();
assert_eq!(code.len(), 1);
assert_eq!(code[0]["name"], "fn_a");
assert_eq!(code[0]["signature"], "fn fn_a()");
let risk = json["risk"].as_array().unwrap();
assert_eq!(risk.len(), 1);
assert_eq!(risk[0]["name"], "fn_a");
assert_eq!(risk[0]["risk"]["risk_level"], "high");
assert_eq!(risk[0]["risk"]["caller_count"], 5);
let tests = json["tests"].as_array().unwrap();
assert_eq!(tests.len(), 1);
assert_eq!(tests[0]["name"], "test_fn_a");
assert_eq!(tests[0]["call_depth"], 1);
let placement = json["placement"].as_array().unwrap();
assert_eq!(placement.len(), 1);
assert_eq!(placement[0]["near_function"], "fn_a");
assert_eq!(placement[0]["reason"], "same module");
}
}