1use devboy_core::{Comment, Discussion, FileDiff, Issue, MergeRequest};
11
12use crate::token_counter::estimate_tokens;
13use crate::toon;
14
15#[derive(Debug, Clone)]
17pub struct TrimNode {
18 pub id: usize,
20 pub kind: NodeKind,
21 pub weight: usize,
23 pub value: f64,
25 pub children: Vec<TrimNode>,
27 pub included: bool,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33pub enum NodeKind {
34 Root,
36 Item {
38 index: usize,
40 },
41 Field { name: String },
43 Text,
45}
46
47impl TrimNode {
48 pub fn new(id: usize, kind: NodeKind, weight: usize) -> Self {
50 Self {
51 id,
52 kind,
53 weight,
54 value: 1.0, children: Vec::new(),
56 included: true,
57 }
58 }
59
60 pub fn count_nodes(&self) -> usize {
62 1 + self.children.iter().map(|c| c.count_nodes()).sum::<usize>()
63 }
64
65 pub fn total_weight(&self) -> usize {
67 if !self.included {
68 return 0;
69 }
70 self.weight
71 + self
72 .children
73 .iter()
74 .map(|c| c.total_weight())
75 .sum::<usize>()
76 }
77
78 pub fn total_value(&self) -> f64 {
80 if !self.included {
81 return 0.0;
82 }
83 self.value * self.weight as f64 + self.children.iter().map(|c| c.total_value()).sum::<f64>()
84 }
85
86 pub fn density(&self) -> f64 {
88 if self.weight == 0 {
89 return 0.0;
90 }
91 self.value / self.weight as f64
92 }
93
94 pub fn included_items_count(&self) -> usize {
96 let self_count = if self.included && matches!(self.kind, NodeKind::Item { .. }) {
97 1
98 } else {
99 0
100 };
101 self_count
102 + self
103 .children
104 .iter()
105 .map(|c| c.included_items_count())
106 .sum::<usize>()
107 }
108
109 pub fn included_item_indices(&self) -> Vec<usize> {
111 let mut indices = Vec::new();
112 self.collect_included_indices(&mut indices);
113 indices
114 }
115
116 fn collect_included_indices(&self, indices: &mut Vec<usize>) {
117 if self.included
118 && let NodeKind::Item { index } = &self.kind
119 {
120 indices.push(*index);
121 }
122 if self.included {
123 for child in &self.children {
124 child.collect_included_indices(indices);
125 }
126 }
127 }
128
129 pub fn excluded_item_indices(&self) -> Vec<usize> {
131 let mut indices = Vec::new();
132 self.collect_excluded_indices(&mut indices);
133 indices
134 }
135
136 fn collect_excluded_indices(&self, indices: &mut Vec<usize>) {
137 if !self.included
138 && let NodeKind::Item { index } = &self.kind
139 {
140 indices.push(*index);
141 } else if self.included {
143 for child in &self.children {
144 child.collect_excluded_indices(indices);
145 }
146 }
147 }
148}
149
150struct IdGen(usize);
156
157impl IdGen {
158 fn new() -> Self {
159 Self(0)
160 }
161
162 fn next(&mut self) -> usize {
163 let id = self.0;
164 self.0 += 1;
165 id
166 }
167}
168
169pub fn build_issues_tree(issues: &[Issue]) -> TrimNode {
178 let mut id_gen = IdGen::new();
179 let mut root = TrimNode::new(id_gen.next(), NodeKind::Root, 0);
180
181 for (i, issue) in issues.iter().enumerate() {
182 let item_weight = estimate_item_tokens(issue);
183 let mut item = TrimNode::new(id_gen.next(), NodeKind::Item { index: i }, item_weight);
184
185 if let Some(desc) = &issue.description
187 && desc.len() > 100
188 {
189 let desc_weight = estimate_tokens(desc);
190 item.weight = item.weight.saturating_sub(desc_weight);
191 let field = TrimNode::new(
192 id_gen.next(),
193 NodeKind::Field {
194 name: "description".into(),
195 },
196 desc_weight,
197 );
198 item.children.push(field);
199 }
200
201 root.children.push(item);
202 }
203
204 root
205}
206
207pub fn build_merge_requests_tree(mrs: &[MergeRequest]) -> TrimNode {
208 let mut id_gen = IdGen::new();
209 let mut root = TrimNode::new(id_gen.next(), NodeKind::Root, 0);
210
211 for (i, mr) in mrs.iter().enumerate() {
212 let item_weight = estimate_item_tokens(mr);
213 let mut item = TrimNode::new(id_gen.next(), NodeKind::Item { index: i }, item_weight);
214
215 if let Some(desc) = &mr.description
216 && desc.len() > 100
217 {
218 let desc_weight = estimate_tokens(desc);
219 item.weight = item.weight.saturating_sub(desc_weight);
220 let field = TrimNode::new(
221 id_gen.next(),
222 NodeKind::Field {
223 name: "description".into(),
224 },
225 desc_weight,
226 );
227 item.children.push(field);
228 }
229
230 root.children.push(item);
231 }
232
233 root
234}
235
236pub fn build_diffs_tree(diffs: &[FileDiff]) -> TrimNode {
240 let mut id_gen = IdGen::new();
241 let mut root = TrimNode::new(id_gen.next(), NodeKind::Root, 0);
242
243 for (i, diff) in diffs.iter().enumerate() {
244 let item_weight = estimate_item_tokens(diff);
245 let mut item = TrimNode::new(id_gen.next(), NodeKind::Item { index: i }, item_weight);
246
247 if !diff.diff.is_empty() {
249 let diff_weight = estimate_tokens(&diff.diff);
250 item.weight = item.weight.saturating_sub(diff_weight);
251 let field = TrimNode::new(
252 id_gen.next(),
253 NodeKind::Field {
254 name: "diff".into(),
255 },
256 diff_weight,
257 );
258 item.children.push(field);
259 }
260
261 root.children.push(item);
262 }
263
264 root
265}
266
267pub fn build_comments_tree(comments: &[Comment]) -> TrimNode {
268 let mut id_gen = IdGen::new();
269 let mut root = TrimNode::new(id_gen.next(), NodeKind::Root, 0);
270
271 for (i, comment) in comments.iter().enumerate() {
272 let item_weight = estimate_item_tokens(comment);
273 let mut item = TrimNode::new(id_gen.next(), NodeKind::Item { index: i }, item_weight);
274
275 if comment.body.len() > 200 {
277 let body_weight = estimate_tokens(&comment.body);
278 item.weight = item.weight.saturating_sub(body_weight);
279 let field = TrimNode::new(
280 id_gen.next(),
281 NodeKind::Field {
282 name: "body".into(),
283 },
284 body_weight,
285 );
286 item.children.push(field);
287 }
288
289 root.children.push(item);
290 }
291
292 root
293}
294
295pub fn build_discussions_tree(discussions: &[Discussion]) -> TrimNode {
299 let mut id_gen = IdGen::new();
300 let mut root = TrimNode::new(id_gen.next(), NodeKind::Root, 0);
301
302 for (i, discussion) in discussions.iter().enumerate() {
303 let metadata_weight = estimate_tokens(&format!(
305 "id:{} resolved:{}",
306 discussion.id, discussion.resolved
307 ));
308 let mut disc_node =
309 TrimNode::new(id_gen.next(), NodeKind::Item { index: i }, metadata_weight);
310
311 for (j, comment) in discussion.comments.iter().enumerate() {
313 let comment_weight = estimate_item_tokens(comment);
314 let comment_node =
315 TrimNode::new(id_gen.next(), NodeKind::Item { index: j }, comment_weight);
316 disc_node.children.push(comment_node);
317 }
318
319 root.children.push(disc_node);
320 }
321
322 root
323}
324
325fn estimate_item_tokens<T: serde::Serialize>(item: &T) -> usize {
327 match toon::encode_value(item) {
328 Ok(encoded) => estimate_tokens(&encoded),
329 Err(_) => {
330 match serde_json::to_string(item) {
332 Ok(json) => estimate_tokens(&json),
333 Err(_) => 50, }
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use devboy_core::User;
343
344 fn sample_issues(n: usize) -> Vec<Issue> {
345 (0..n)
346 .map(|i| Issue {
347 key: format!("gh#{}", i + 1),
348 title: format!("Issue {}", i + 1),
349 description: if i % 2 == 0 {
350 Some("A".repeat(200)) } else {
352 Some("Short desc".into())
353 },
354 state: "open".into(),
355 source: "github".into(),
356 priority: None,
357 labels: vec!["bug".into()],
358 author: Some(User {
359 id: format!("{}", i),
360 username: format!("user{}", i),
361 name: None,
362 email: None,
363 avatar_url: None,
364 }),
365 assignees: vec![],
366 url: Some(format!("https://github.com/test/repo/issues/{}", i + 1)),
367 created_at: Some("2024-01-01T00:00:00Z".into()),
368 updated_at: Some("2024-01-02T00:00:00Z".into()),
369 attachments_count: None,
370 parent: None,
371 subtasks: vec![],
372 custom_fields: std::collections::HashMap::new(),
373 ..Default::default()
374 })
375 .collect()
376 }
377
378 fn sample_diffs(n: usize) -> Vec<FileDiff> {
379 (0..n)
380 .map(|i| FileDiff {
381 file_path: format!("src/file_{}.rs", i),
382 old_path: None,
383 new_file: false,
384 deleted_file: false,
385 renamed_file: false,
386 diff: format!("+added line {}\n-removed line {}", i, i),
387 additions: Some(1),
388 deletions: Some(1),
389 })
390 .collect()
391 }
392
393 fn sample_comments(n: usize) -> Vec<Comment> {
394 (0..n)
395 .map(|i| Comment {
396 id: format!("{}", i),
397 body: format!("Comment body {}", i),
398 author: None,
399 created_at: Some("2024-01-01T00:00:00Z".into()),
400 updated_at: None,
401 position: None,
402 })
403 .collect()
404 }
405
406 fn sample_discussions(n: usize) -> Vec<Discussion> {
407 (0..n)
408 .map(|i| Discussion {
409 id: format!("{}", i),
410 resolved: i % 2 == 0,
411 resolved_by: None,
412 comments: vec![
413 Comment {
414 id: format!("c{}a", i),
415 body: format!("First comment in discussion {}", i),
416 author: None,
417 created_at: None,
418 updated_at: None,
419 position: None,
420 },
421 Comment {
422 id: format!("c{}b", i),
423 body: format!("Reply in discussion {}", i),
424 author: None,
425 created_at: None,
426 updated_at: None,
427 position: None,
428 },
429 ],
430 position: None,
431 })
432 .collect()
433 }
434
435 #[test]
438 fn test_build_issues_tree_structure() {
439 let issues = sample_issues(5);
440 let tree = build_issues_tree(&issues);
441
442 assert_eq!(tree.kind, NodeKind::Root);
443 assert_eq!(tree.children.len(), 5);
444 assert!(tree.weight == 0); for (i, child) in tree.children.iter().enumerate() {
448 assert_eq!(child.kind, NodeKind::Item { index: i });
449 assert!(child.weight > 0);
450 assert!(child.included);
451 }
452 }
453
454 #[test]
455 fn test_build_issues_tree_with_description_fields() {
456 let issues = sample_issues(4);
457 let tree = build_issues_tree(&issues);
458
459 assert!(
461 !tree.children[0].children.is_empty(),
462 "Issue 0 should have description field"
463 );
464 assert!(
465 tree.children[1].children.is_empty(),
466 "Issue 1 should not have description field (short)"
467 );
468 assert!(!tree.children[2].children.is_empty());
469 assert!(tree.children[3].children.is_empty());
470 }
471
472 #[test]
473 fn test_build_diffs_tree_structure() {
474 let diffs = sample_diffs(3);
475 let tree = build_diffs_tree(&diffs);
476
477 assert_eq!(tree.children.len(), 3);
478 for child in &tree.children {
480 assert_eq!(child.children.len(), 1);
481 assert_eq!(
482 child.children[0].kind,
483 NodeKind::Field {
484 name: "diff".into()
485 }
486 );
487 }
488 }
489
490 #[test]
491 fn test_build_comments_tree_structure() {
492 let comments = sample_comments(5);
493 let tree = build_comments_tree(&comments);
494
495 assert_eq!(tree.children.len(), 5);
496 for child in &tree.children {
498 assert!(child.children.is_empty());
499 }
500 }
501
502 #[test]
503 fn test_build_discussions_tree_structure() {
504 let discussions = sample_discussions(3);
505 let tree = build_discussions_tree(&discussions);
506
507 assert_eq!(tree.children.len(), 3);
508 for disc in &tree.children {
510 assert_eq!(disc.children.len(), 2);
511 }
512 }
513
514 #[test]
515 fn test_build_merge_requests_tree() {
516 let mrs: Vec<MergeRequest> = (0..3)
517 .map(|i| MergeRequest {
518 key: format!("pr#{}", i),
519 title: format!("PR {}", i),
520 description: Some("A".repeat(200)),
521 state: "open".into(),
522 source: "github".into(),
523 source_branch: "feat".into(),
524 target_branch: "main".into(),
525 author: None,
526 assignees: vec![],
527 reviewers: vec![],
528 labels: vec![],
529 draft: false,
530 url: None,
531 created_at: None,
532 updated_at: None,
533 })
534 .collect();
535
536 let tree = build_merge_requests_tree(&mrs);
537 assert_eq!(tree.children.len(), 3);
538 for child in &tree.children {
540 assert!(!child.children.is_empty());
541 }
542 }
543
544 #[test]
547 fn test_count_nodes() {
548 let issues = sample_issues(5);
549 let tree = build_issues_tree(&issues);
550
551 assert!(tree.count_nodes() >= 6);
553 }
554
555 #[test]
556 fn test_total_weight() {
557 let issues = sample_issues(3);
558 let tree = build_issues_tree(&issues);
559
560 let total = tree.total_weight();
561 assert!(total > 0);
562
563 let manual_sum: usize = tree.children.iter().map(|c| c.total_weight()).sum();
565 assert_eq!(total, manual_sum); }
567
568 #[test]
569 fn test_included_items_count() {
570 let issues = sample_issues(5);
571 let mut tree = build_issues_tree(&issues);
572
573 assert_eq!(tree.included_items_count(), 5);
574
575 tree.children[1].included = false;
577 tree.children[3].included = false;
578
579 assert_eq!(tree.included_items_count(), 3);
580 }
581
582 #[test]
583 fn test_included_excluded_indices() {
584 let issues = sample_issues(5);
585 let mut tree = build_issues_tree(&issues);
586
587 tree.children[1].included = false;
588 tree.children[3].included = false;
589
590 let included = tree.included_item_indices();
591 let excluded = tree.excluded_item_indices();
592
593 assert_eq!(included, vec![0, 2, 4]);
594 assert_eq!(excluded, vec![1, 3]);
595 }
596
597 #[test]
600 fn test_weights_are_positive() {
601 let issues = sample_issues(10);
602 let tree = build_issues_tree(&issues);
603
604 for child in &tree.children {
605 assert!(
606 child.weight > 0 || !child.children.is_empty(),
607 "Item should have weight or children with weight"
608 );
609 assert!(child.total_weight() > 0);
610 }
611 }
612
613 #[test]
614 fn test_total_weight_decreases_when_excluded() {
615 let issues = sample_issues(5);
616 let mut tree = build_issues_tree(&issues);
617
618 let full_weight = tree.total_weight();
619 tree.children[0].included = false;
620 let reduced_weight = tree.total_weight();
621
622 assert!(reduced_weight < full_weight);
623 }
624
625 #[test]
628 fn test_density_calculation() {
629 let mut node = TrimNode::new(0, NodeKind::Item { index: 0 }, 100);
630 node.value = 0.5;
631 assert!((node.density() - 0.005).abs() < 0.0001);
632
633 let zero_node = TrimNode::new(1, NodeKind::Item { index: 1 }, 0);
634 assert_eq!(zero_node.density(), 0.0);
635 }
636}