1use crate::ast_parser::{AstParser, EntityLocation, EntityQuery};
8use scribe_core::{Result, ScribeError};
9use scribe_graph::{DependencyGraph, TraversalDirection};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CoveringSetOptions {
17 pub include_dependencies: bool,
19 pub include_dependents: bool,
21 pub max_depth: Option<usize>,
23 pub max_files: Option<usize>,
25 pub min_importance: Option<f64>,
27}
28
29impl Default for CoveringSetOptions {
30 fn default() -> Self {
31 Self {
32 include_dependencies: true,
33 include_dependents: false,
34 max_depth: None,
35 max_files: None,
36 min_importance: None,
37 }
38 }
39}
40
41impl CoveringSetOptions {
42 pub fn for_understanding() -> Self {
44 Self {
45 include_dependencies: true,
46 include_dependents: false,
47 max_depth: None, max_files: Some(100), min_importance: Some(0.3), }
51 }
52
53 pub fn for_impact_analysis() -> Self {
55 Self {
56 include_dependencies: true,
57 include_dependents: true, max_depth: Some(2), max_files: Some(50),
60 min_importance: Some(0.4),
61 }
62 }
63
64 pub fn minimal() -> Self {
66 Self {
67 include_dependencies: true,
68 include_dependents: false,
69 max_depth: Some(1), max_files: Some(20),
71 min_importance: Some(0.5),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CoveringSetResult {
79 pub target_entity: Option<EntityLocation>,
81 pub files: Vec<CoveringSetFile>,
83 pub statistics: CoveringSetStatistics,
85 pub inclusion_reasons: HashMap<String, String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct CoveringSetFile {
92 pub path: String,
94 pub reason: InclusionReason,
96 pub distance: usize,
98 pub importance: Option<f64>,
100 pub line_ranges: Vec<LineRange>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct LineRange {
107 pub start_line: usize,
108 pub end_line: usize,
109}
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub enum InclusionReason {
113 TargetFile,
115 ChangedFile,
117 DirectDependency,
119 TransitiveDependency,
121 DirectDependent,
123 TransitiveDependent,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CoveringSetStatistics {
130 pub files_examined: usize,
132 pub files_selected: usize,
134 pub files_excluded: usize,
136 pub max_depth_reached: usize,
138 pub limits_reached: bool,
140}
141
142pub struct CoveringSetComputer {
144 ast_parser: AstParser,
145}
146
147impl CoveringSetComputer {
148 pub fn new() -> Result<Self> {
150 Ok(Self {
151 ast_parser: AstParser::new()?,
152 })
153 }
154
155 pub fn compute_covering_set(
166 &mut self,
167 query: &EntityQuery,
168 file_contents: &HashMap<String, String>,
169 graph: &DependencyGraph,
170 options: &CoveringSetOptions,
171 ) -> Result<CoveringSetResult> {
172 let mut statistics = CoveringSetStatistics {
173 files_examined: file_contents.len(),
174 files_selected: 0,
175 files_excluded: 0,
176 max_depth_reached: 0,
177 limits_reached: false,
178 };
179
180 let target_entity = self.find_target_entity(query, file_contents)?;
182
183 if target_entity.is_none() {
184 return Ok(CoveringSetResult {
185 target_entity: None,
186 files: Vec::new(),
187 statistics,
188 inclusion_reasons: HashMap::new(),
189 });
190 }
191
192 let target = target_entity.clone().unwrap();
193 let target_file = &target.file_path;
194
195 let mut seed_files = vec![target_file.clone()];
197 let direction = self.get_traversal_direction(options);
198 let closure_files = graph.compute_closure(&seed_files, direction, options.max_depth);
199
200 let mut covering_set = Vec::new();
202 let mut inclusion_reasons = HashMap::new();
203
204 covering_set.push(CoveringSetFile {
206 path: target_file.clone(),
207 reason: InclusionReason::TargetFile,
208 distance: 0,
209 importance: None,
210 line_ranges: vec![LineRange {
211 start_line: target.start_line,
212 end_line: target.end_line,
213 }],
214 });
215 inclusion_reasons.insert(
216 target_file.clone(),
217 "Contains the target entity".to_string(),
218 );
219
220 for file in closure_files {
222 if file == *target_file {
223 continue; }
225
226 let (reason, distance) = self.compute_inclusion_info(
227 &file,
228 target_file,
229 graph,
230 options,
231 );
232
233 covering_set.push(CoveringSetFile {
234 path: file.clone(),
235 reason: reason.clone(),
236 distance,
237 importance: None,
238 line_ranges: Vec::new(),
239 });
240
241 let reason_text = self.format_inclusion_reason(&reason, distance);
242 inclusion_reasons.insert(file, reason_text);
243 }
244
245 let original_count = covering_set.len();
247 self.apply_limits(&mut covering_set, options, &mut statistics);
248
249 if covering_set.len() < original_count {
250 statistics.limits_reached = true;
251 statistics.files_excluded = original_count - covering_set.len();
252 }
253
254 statistics.files_selected = covering_set.len();
255 statistics.max_depth_reached = covering_set
256 .iter()
257 .map(|f| f.distance)
258 .max()
259 .unwrap_or(0);
260
261 Ok(CoveringSetResult {
262 target_entity,
263 files: covering_set,
264 statistics,
265 inclusion_reasons,
266 })
267 }
268
269 pub fn compute_covering_set_for_files(
275 &self,
276 changed_files: &[String],
277 graph: &DependencyGraph,
278 line_map: Option<&HashMap<String, Vec<LineRange>>>,
279 options: &CoveringSetOptions,
280 ) -> Result<CoveringSetResult> {
281 let mut statistics = CoveringSetStatistics {
282 files_examined: changed_files.len(),
283 files_selected: 0,
284 files_excluded: 0,
285 max_depth_reached: 0,
286 limits_reached: false,
287 };
288
289 if changed_files.is_empty() {
290 return Ok(CoveringSetResult {
291 target_entity: None,
292 files: Vec::new(),
293 statistics,
294 inclusion_reasons: HashMap::new(),
295 });
296 }
297
298 let direction = self.get_traversal_direction(options);
299 let closure_files = graph.compute_closure(changed_files, direction, options.max_depth);
300
301 let mut covering_set = Vec::new();
302 let mut inclusion_reasons = HashMap::new();
303
304 for changed in changed_files {
306 covering_set.push(CoveringSetFile {
307 path: changed.clone(),
308 reason: InclusionReason::ChangedFile,
309 distance: 0,
310 importance: None,
311 line_ranges: line_map
312 .and_then(|m| m.get(changed))
313 .cloned()
314 .unwrap_or_default(),
315 });
316 inclusion_reasons.insert(changed.clone(), "Changed in diff".to_string());
317 }
318
319 for file in closure_files {
321 if changed_files.contains(&file) {
322 continue;
323 }
324
325 let reference = changed_files
328 .iter()
329 .find(|target| {
330 graph.contains_edge(target, &file) || graph.contains_edge(&file, target)
331 })
332 .unwrap_or(&changed_files[0]);
333
334 let (reason, distance) = self.compute_inclusion_info(&file, reference, graph, options);
335
336 covering_set.push(CoveringSetFile {
337 path: file.clone(),
338 reason: reason.clone(),
339 distance,
340 importance: None,
341 line_ranges: Vec::new(),
342 });
343
344 let reason_text = self.format_inclusion_reason(&reason, distance);
345 inclusion_reasons.insert(file, reason_text);
346 }
347
348 let original_count = covering_set.len();
349 self.apply_limits(&mut covering_set, options, &mut statistics);
350
351 if covering_set.len() < original_count {
352 statistics.limits_reached = true;
353 statistics.files_excluded = original_count - covering_set.len();
354 }
355
356 statistics.files_selected = covering_set.len();
357 statistics.max_depth_reached = covering_set
358 .iter()
359 .map(|f| f.distance)
360 .max()
361 .unwrap_or(0);
362
363 Ok(CoveringSetResult {
364 target_entity: None,
365 files: covering_set,
366 statistics,
367 inclusion_reasons,
368 })
369 }
370
371 fn find_target_entity(
373 &mut self,
374 query: &EntityQuery,
375 file_contents: &HashMap<String, String>,
376 ) -> Result<Option<EntityLocation>> {
377 for (file_path, content) in file_contents {
378 let entities = self.ast_parser.find_entities(content, file_path, query)?;
379 if let Some(entity) = entities.first() {
380 return Ok(Some(entity.clone()));
381 }
382 }
383 Ok(None)
384 }
385
386 fn get_traversal_direction(&self, options: &CoveringSetOptions) -> TraversalDirection {
388 match (options.include_dependencies, options.include_dependents) {
389 (true, true) => TraversalDirection::Both,
390 (true, false) => TraversalDirection::Dependencies,
391 (false, true) => TraversalDirection::Dependents,
392 (false, false) => TraversalDirection::Dependencies, }
394 }
395
396 fn compute_inclusion_info(
398 &self,
399 file: &str,
400 target_file: &str,
401 graph: &DependencyGraph,
402 options: &CoveringSetOptions,
403 ) -> (InclusionReason, usize) {
404 let file_string = file.to_string();
405 let target_string = target_file.to_string();
406
407 if graph.contains_edge(&target_string, &file_string) {
409 return (InclusionReason::DirectDependency, 1);
410 }
411
412 if graph.contains_edge(&file_string, &target_string) {
414 return (InclusionReason::DirectDependent, 1);
415 }
416
417 let deps = graph.transitive_dependencies(&target_string, options.max_depth);
419 if deps.contains(&file_string) {
420 let distance = self.compute_distance(target_file, file, graph);
421 return (InclusionReason::TransitiveDependency, distance);
422 }
423
424 let dependents = graph.transitive_dependents(&target_string, options.max_depth);
425 if dependents.contains(&file_string) {
426 let distance = self.compute_distance(file, target_file, graph);
427 return (InclusionReason::TransitiveDependent, distance);
428 }
429
430 (InclusionReason::TransitiveDependency, 2)
432 }
433
434 fn compute_distance(&self, from: &str, to: &str, graph: &DependencyGraph) -> usize {
436 use std::collections::{HashSet, VecDeque};
437
438 let mut queue = VecDeque::new();
439 let mut visited = HashSet::new();
440
441 queue.push_back((from.to_string(), 0));
442 visited.insert(from.to_string());
443
444 while let Some((current, dist)) = queue.pop_front() {
445 if current == to {
446 return dist;
447 }
448
449 if let Some(neighbors) = graph.outgoing_neighbors(¤t) {
450 for neighbor in neighbors {
451 if !visited.contains(neighbor) {
452 visited.insert(neighbor.to_string());
453 queue.push_back((neighbor.to_string(), dist + 1));
454 }
455 }
456 }
457 }
458
459 999
461 }
462
463 fn format_inclusion_reason(&self, reason: &InclusionReason, distance: usize) -> String {
465 match reason {
466 InclusionReason::TargetFile => "Contains the target entity".to_string(),
467 InclusionReason::ChangedFile => "Changed in diff".to_string(),
468 InclusionReason::DirectDependency => "Direct dependency of target".to_string(),
469 InclusionReason::TransitiveDependency => {
470 format!("Transitive dependency (distance: {})", distance)
471 }
472 InclusionReason::DirectDependent => "Directly depends on target".to_string(),
473 InclusionReason::TransitiveDependent => {
474 format!("Transitively depends on target (distance: {})", distance)
475 }
476 }
477 }
478
479 fn apply_limits(
481 &self,
482 covering_set: &mut Vec<CoveringSetFile>,
483 options: &CoveringSetOptions,
484 statistics: &mut CoveringSetStatistics,
485 ) {
486 covering_set.sort_by_key(|f| (f.distance, f.path.clone()));
488
489 if let Some(max_files) = options.max_files {
491 if covering_set.len() > max_files {
492 covering_set.truncate(max_files);
493 statistics.limits_reached = true;
494 }
495 }
496
497 if let Some(min_importance) = options.min_importance {
499 covering_set.retain(|f| {
500 if matches!(f.reason, InclusionReason::TargetFile) {
502 return true;
503 }
504 f.importance.map_or(true, |imp| imp >= min_importance)
506 });
507 }
508 }
509}
510
511impl Default for CoveringSetComputer {
512 fn default() -> Self {
513 Self::new().expect("Failed to create CoveringSetComputer")
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::ast_parser::EntityQuery;
521
522 #[test]
523 fn test_covering_set_options() {
524 let opts = CoveringSetOptions::default();
525 assert!(opts.include_dependencies);
526 assert!(!opts.include_dependents);
527
528 let minimal = CoveringSetOptions::minimal();
529 assert_eq!(minimal.max_depth, Some(1));
530 assert_eq!(minimal.max_files, Some(20));
531 }
532
533 #[test]
534 fn test_covering_set_computer_creation() {
535 let computer = CoveringSetComputer::new();
536 assert!(computer.is_ok());
537 }
538
539 #[test]
540 fn test_inclusion_reason_formatting() {
541 let computer = CoveringSetComputer::new().unwrap();
542
543 let reason = computer.format_inclusion_reason(&InclusionReason::TargetFile, 0);
544 assert_eq!(reason, "Contains the target entity");
545
546 let reason = computer.format_inclusion_reason(&InclusionReason::ChangedFile, 0);
547 assert_eq!(reason, "Changed in diff");
548
549 let reason = computer.format_inclusion_reason(&InclusionReason::DirectDependency, 1);
550 assert_eq!(reason, "Direct dependency of target");
551
552 let reason = computer.format_inclusion_reason(&InclusionReason::TransitiveDependency, 3);
553 assert!(reason.contains("distance: 3"));
554 }
555
556 #[test]
557 fn test_covering_set_for_changed_files() {
558 let computer = CoveringSetComputer::new().unwrap();
559 let graph = DependencyGraph::new();
560
561 let changed = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
562 let result = computer
563 .compute_covering_set_for_files(
564 &changed,
565 &graph,
566 None,
567 &CoveringSetOptions::default(),
568 )
569 .unwrap();
570
571 assert!(result.target_entity.is_none());
572 assert_eq!(result.files.len(), 2);
573 assert!(result
574 .files
575 .iter()
576 .all(|f| f.reason == InclusionReason::ChangedFile));
577 }
578}