1use crate::strategy::{TrimStrategyKind, create_strategy};
12use crate::token_counter::estimate_tokens;
13use crate::toon::{self, TrimLevel};
14use crate::tree::TrimNode;
15use crate::trim;
16use crate::truncation;
17
18#[derive(Debug, Clone)]
19pub struct BudgetConfig {
20 pub budget_tokens: usize,
22 pub margin: f64,
24 pub max_iterations: usize,
26}
27
28impl Default for BudgetConfig {
29 fn default() -> Self {
30 Self {
31 budget_tokens: 8000,
32 margin: 0.20,
33 max_iterations: 3,
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
40pub struct BudgetResult {
41 pub content: String,
43 pub tokens: usize,
45 pub trimmed: bool,
47 pub overflow_indices: Vec<usize>,
49 pub total_items: usize,
51 pub included_items: usize,
53}
54
55pub fn run(tree: &mut TrimNode, full_encoded: &str, config: &BudgetConfig) -> BudgetResult {
64 let full_tokens = estimate_tokens(full_encoded);
65 let total_items = tree.included_items_count();
66
67 if full_tokens <= config.budget_tokens {
69 return BudgetResult {
70 content: full_encoded.to_string(),
71 tokens: full_tokens,
72 trimmed: false,
73 overflow_indices: vec![],
74 total_items,
75 included_items: total_items,
76 };
77 }
78
79 let r = if full_tokens > 0 {
81 full_tokens as f64 / tree.total_weight() as f64
82 } else {
83 1.0
84 };
85
86 let effective_budget = config.budget_tokens as f64 * (1.0 - config.margin);
88 let mut b_trim = if r > 0.0 {
89 (effective_budget / r) as usize
90 } else {
91 config.budget_tokens
92 };
93
94 for _iteration in 0..config.max_iterations {
96 trim::trim(tree, b_trim);
98
99 let included_items = tree.included_items_count();
100 let overflow = tree.excluded_item_indices();
101 let current_weight = tree.total_weight();
102
103 let estimated_tokens = if tree.total_weight() > 0 {
105 (current_weight as f64 * r) as usize
106 } else {
107 0
108 };
109
110 if estimated_tokens <= config.budget_tokens {
111 return BudgetResult {
113 content: String::new(), tokens: estimated_tokens,
115 trimmed: true,
116 overflow_indices: overflow,
117 total_items,
118 included_items,
119 };
120 }
121
122 if estimated_tokens > 0 {
124 b_trim =
125 (b_trim as f64 * config.budget_tokens as f64 / estimated_tokens as f64) as usize;
126 } else {
127 break;
128 }
129 }
130
131 let overflow = tree.excluded_item_indices();
133 let included_items = tree.included_items_count();
134
135 BudgetResult {
136 content: String::new(),
137 tokens: 0,
138 trimmed: true,
139 overflow_indices: overflow,
140 total_items,
141 included_items,
142 }
143}
144
145pub fn process_issues(
147 issues: &[devboy_core::Issue],
148 strategy_kind: TrimStrategyKind,
149 config: &BudgetConfig,
150) -> devboy_core::Result<BudgetResult> {
151 let full_encoded = toon::encode_issues(issues, TrimLevel::Full)?;
153 let full_tokens = estimate_tokens(&full_encoded);
154
155 if full_tokens <= config.budget_tokens {
157 return Ok(BudgetResult {
158 content: full_encoded,
159 tokens: full_tokens,
160 trimmed: false,
161 overflow_indices: vec![],
162 total_items: issues.len(),
163 included_items: issues.len(),
164 });
165 }
166
167 let mut tree = crate::tree::build_issues_tree(issues);
169 let strategy = create_strategy(strategy_kind);
170 strategy.assign_values(&mut tree);
171
172 let mut result = run(&mut tree, &full_encoded, config);
174
175 if result.trimmed {
177 let included_indices = tree.included_item_indices();
178 let included_issues: Vec<devboy_core::Issue> = included_indices
179 .iter()
180 .map(|&i| issues[i].clone())
181 .collect();
182
183 for level in [TrimLevel::Full, TrimLevel::Standard, TrimLevel::Minimal] {
185 let encoded = toon::encode_issues(&included_issues, level)?;
186 let tokens = estimate_tokens(&encoded);
187 if tokens <= config.budget_tokens {
188 result.content = encoded;
189 result.tokens = tokens;
190 return Ok(result);
191 }
192 }
193
194 let encoded = toon::encode_issues(&included_issues, TrimLevel::Minimal)?;
196 let max_chars = crate::token_counter::tokens_to_chars(config.budget_tokens);
197 result.content = truncation::truncate_string(&encoded, max_chars);
198 result.tokens = estimate_tokens(&result.content);
199 }
200
201 Ok(result)
202}
203
204pub fn process_merge_requests(
206 mrs: &[devboy_core::MergeRequest],
207 strategy_kind: TrimStrategyKind,
208 config: &BudgetConfig,
209) -> devboy_core::Result<BudgetResult> {
210 let full_encoded = toon::encode_merge_requests(mrs, TrimLevel::Full)?;
211 let full_tokens = estimate_tokens(&full_encoded);
212
213 if full_tokens <= config.budget_tokens {
214 return Ok(BudgetResult {
215 content: full_encoded,
216 tokens: full_tokens,
217 trimmed: false,
218 overflow_indices: vec![],
219 total_items: mrs.len(),
220 included_items: mrs.len(),
221 });
222 }
223
224 let mut tree = crate::tree::build_merge_requests_tree(mrs);
225 let strategy = create_strategy(strategy_kind);
226 strategy.assign_values(&mut tree);
227
228 let mut result = run(&mut tree, &full_encoded, config);
229
230 if result.trimmed {
231 let included_indices = tree.included_item_indices();
232 let included_mrs: Vec<devboy_core::MergeRequest> =
233 included_indices.iter().map(|&i| mrs[i].clone()).collect();
234
235 for level in [TrimLevel::Full, TrimLevel::Standard, TrimLevel::Minimal] {
236 let encoded = toon::encode_merge_requests(&included_mrs, level)?;
237 let tokens = estimate_tokens(&encoded);
238 if tokens <= config.budget_tokens {
239 result.content = encoded;
240 result.tokens = tokens;
241 return Ok(result);
242 }
243 }
244
245 let encoded = toon::encode_merge_requests(&included_mrs, TrimLevel::Minimal)?;
246 let max_chars = crate::token_counter::tokens_to_chars(config.budget_tokens);
247 result.content = truncation::truncate_string(&encoded, max_chars);
248 result.tokens = estimate_tokens(&result.content);
249 }
250
251 Ok(result)
252}
253
254pub fn process_diffs(
256 diffs: &[devboy_core::FileDiff],
257 strategy_kind: TrimStrategyKind,
258 config: &BudgetConfig,
259) -> devboy_core::Result<BudgetResult> {
260 let full_encoded = toon::encode_diffs(diffs)?;
261 let full_tokens = estimate_tokens(&full_encoded);
262
263 if full_tokens <= config.budget_tokens {
264 return Ok(BudgetResult {
265 content: full_encoded,
266 tokens: full_tokens,
267 trimmed: false,
268 overflow_indices: vec![],
269 total_items: diffs.len(),
270 included_items: diffs.len(),
271 });
272 }
273
274 let mut tree = crate::tree::build_diffs_tree(diffs);
275 let strategy = create_strategy(strategy_kind);
276 strategy.assign_values(&mut tree);
277
278 let mut result = run(&mut tree, &full_encoded, config);
279
280 if result.trimmed {
281 let included_indices = tree.included_item_indices();
282 let included_diffs: Vec<devboy_core::FileDiff> =
283 included_indices.iter().map(|&i| diffs[i].clone()).collect();
284
285 let encoded = toon::encode_diffs(&included_diffs)?;
286 let tokens = estimate_tokens(&encoded);
287 if tokens <= config.budget_tokens {
288 result.content = encoded;
289 result.tokens = tokens;
290 } else {
291 let max_chars = crate::token_counter::tokens_to_chars(config.budget_tokens);
292 result.content = truncation::truncate_string(&encoded, max_chars);
293 result.tokens = estimate_tokens(&result.content);
294 }
295 }
296
297 Ok(result)
298}
299
300pub fn process_discussions(
302 discussions: &[devboy_core::Discussion],
303 strategy_kind: TrimStrategyKind,
304 config: &BudgetConfig,
305) -> devboy_core::Result<BudgetResult> {
306 let full_encoded = toon::encode_discussions(discussions)?;
307 let full_tokens = estimate_tokens(&full_encoded);
308
309 if full_tokens <= config.budget_tokens {
310 return Ok(BudgetResult {
311 content: full_encoded,
312 tokens: full_tokens,
313 trimmed: false,
314 overflow_indices: vec![],
315 total_items: discussions.len(),
316 included_items: discussions.len(),
317 });
318 }
319
320 let mut tree = crate::tree::build_discussions_tree(discussions);
321 let strategy = create_strategy(strategy_kind);
322 strategy.assign_values(&mut tree);
323
324 let mut result = run(&mut tree, &full_encoded, config);
325
326 if result.trimmed {
327 let included_indices = tree.included_item_indices();
328 let included_discussions: Vec<devboy_core::Discussion> = included_indices
329 .iter()
330 .map(|&i| discussions[i].clone())
331 .collect();
332
333 let encoded = toon::encode_discussions(&included_discussions)?;
334 let tokens = estimate_tokens(&encoded);
335 if tokens <= config.budget_tokens {
336 result.content = encoded;
337 result.tokens = tokens;
338 } else {
339 let max_chars = crate::token_counter::tokens_to_chars(config.budget_tokens);
340 result.content = truncation::truncate_string(&encoded, max_chars);
341 result.tokens = estimate_tokens(&result.content);
342 }
343 }
344
345 Ok(result)
346}
347
348pub fn process_comments(
350 comments: &[devboy_core::Comment],
351 strategy_kind: TrimStrategyKind,
352 config: &BudgetConfig,
353) -> devboy_core::Result<BudgetResult> {
354 let full_encoded = toon::encode_comments(comments)?;
355 let full_tokens = estimate_tokens(&full_encoded);
356
357 if full_tokens <= config.budget_tokens {
358 return Ok(BudgetResult {
359 content: full_encoded,
360 tokens: full_tokens,
361 trimmed: false,
362 overflow_indices: vec![],
363 total_items: comments.len(),
364 included_items: comments.len(),
365 });
366 }
367
368 let mut tree = crate::tree::build_comments_tree(comments);
369 let strategy = create_strategy(strategy_kind);
370 strategy.assign_values(&mut tree);
371
372 let mut result = run(&mut tree, &full_encoded, config);
373
374 if result.trimmed {
375 let included_indices = tree.included_item_indices();
376 let included_comments: Vec<devboy_core::Comment> = included_indices
377 .iter()
378 .map(|&i| comments[i].clone())
379 .collect();
380
381 let encoded = toon::encode_comments(&included_comments)?;
382 let tokens = estimate_tokens(&encoded);
383 if tokens <= config.budget_tokens {
384 result.content = encoded;
385 result.tokens = tokens;
386 } else {
387 let max_chars = crate::token_counter::tokens_to_chars(config.budget_tokens);
388 result.content = truncation::truncate_string(&encoded, max_chars);
389 result.tokens = estimate_tokens(&result.content);
390 }
391 }
392
393 Ok(result)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::tree::NodeKind;
400
401 fn make_tree(weights: &[usize], values: &[f64]) -> TrimNode {
402 let mut root = TrimNode::new(0, NodeKind::Root, 0);
403 for (i, (&w, &v)) in weights.iter().zip(values.iter()).enumerate() {
404 let mut node = TrimNode::new(i + 1, NodeKind::Item { index: i }, w);
405 node.value = v;
406 root.children.push(node);
407 }
408 root
409 }
410
411 #[test]
412 fn test_budget_fast_path() {
413 let mut tree = make_tree(&[10, 10, 10], &[1.0, 1.0, 1.0]);
414 let content = "short content";
415 let config = BudgetConfig {
416 budget_tokens: 100,
417 ..Default::default()
418 };
419
420 let result = run(&mut tree, content, &config);
421 assert!(!result.trimmed);
422 assert_eq!(result.content, "short content");
423 assert!(result.overflow_indices.is_empty());
424 }
425
426 #[test]
427 fn test_budget_triggers_trimming() {
428 let mut tree = make_tree(&[100, 100, 100], &[1.0, 0.5, 0.2]);
429 let content = "x".repeat(1000); let config = BudgetConfig {
432 budget_tokens: 50,
433 margin: 0.20,
434 max_iterations: 3,
435 };
436
437 let result = run(&mut tree, &content, &config);
438 assert!(result.trimmed);
439 assert!(result.included_items < 3);
440 assert!(!result.overflow_indices.is_empty());
441 }
442
443 #[test]
444 fn test_budget_config_defaults() {
445 let config = BudgetConfig::default();
446 assert_eq!(config.budget_tokens, 8000);
447 assert!((config.margin - 0.20).abs() < 0.001);
448 assert_eq!(config.max_iterations, 3);
449 }
450
451 #[test]
452 fn test_budget_result_fields() {
453 let mut tree = make_tree(&[50, 50, 50], &[1.0, 0.8, 0.3]);
454 let content = "x".repeat(600); let config = BudgetConfig {
456 budget_tokens: 30,
457 margin: 0.10,
458 max_iterations: 2,
459 };
460
461 let result = run(&mut tree, &content, &config);
462 assert_eq!(result.total_items, 3);
463 assert!(result.included_items <= 3);
464 assert_eq!(
465 result.included_items + result.overflow_indices.len(),
466 result.total_items
467 );
468 }
469
470 fn sample_issues(n: usize) -> Vec<devboy_core::Issue> {
473 (0..n)
474 .map(|i| devboy_core::Issue {
475 key: format!("gh#{}", i + 1),
476 title: format!("Issue {}", i + 1),
477 description: Some(format!(
478 "Description for issue {} with enough text to make it non-trivial for token counting purposes",
479 i + 1
480 )),
481 state: "open".into(),
482 source: "github".into(),
483 priority: None,
484 labels: vec!["bug".into()],
485 author: Some(devboy_core::User {
486 id: format!("{}", i),
487 username: format!("user{}", i),
488 name: None,
489 email: None,
490 avatar_url: None,
491 }),
492 assignees: vec![],
493 url: Some(format!("https://github.com/test/repo/issues/{}", i + 1)),
494 created_at: Some("2024-01-01T00:00:00Z".into()),
495 updated_at: Some("2024-01-02T00:00:00Z".into()),
496 attachments_count: None,
497 parent: None,
498 subtasks: vec![],
499 custom_fields: std::collections::HashMap::new(),
500 })
501 .collect()
502 }
503
504 #[test]
505 fn test_process_issues_fast_path() {
506 let issues = sample_issues(2);
507 let config = BudgetConfig {
508 budget_tokens: 50000,
509 ..Default::default()
510 };
511 let result = process_issues(&issues, TrimStrategyKind::ElementCount, &config).unwrap();
512 assert!(!result.trimmed);
513 assert!(!result.content.is_empty());
514 assert_eq!(result.total_items, 2);
515 assert_eq!(result.included_items, 2);
516 }
517
518 #[test]
519 fn test_process_issues_with_trimming() {
520 let issues = sample_issues(20);
521 let config = BudgetConfig {
522 budget_tokens: 500,
523 margin: 0.20,
524 max_iterations: 3,
525 };
526 let result = process_issues(&issues, TrimStrategyKind::ElementCount, &config).unwrap();
527 assert!(result.trimmed);
528 assert!(result.included_items < 20);
529 assert!(!result.content.is_empty());
530 assert!(result.tokens <= config.budget_tokens);
531 }
532
533 #[test]
534 fn test_process_issues_with_cascading_strategy() {
535 let issues = sample_issues(10);
536 let config = BudgetConfig {
537 budget_tokens: 300,
538 margin: 0.20,
539 max_iterations: 3,
540 };
541 let result = process_issues(&issues, TrimStrategyKind::Cascading, &config).unwrap();
542 assert!(result.trimmed);
543 assert!(!result.content.is_empty());
544 }
545
546 #[test]
547 fn test_process_issues_very_small_budget() {
548 let issues = sample_issues(10);
549 let config = BudgetConfig {
550 budget_tokens: 50,
551 margin: 0.20,
552 max_iterations: 3,
553 };
554 let result = process_issues(&issues, TrimStrategyKind::Default, &config).unwrap();
555 assert!(result.trimmed);
556 assert!(!result.content.is_empty());
558 }
559
560 #[test]
561 fn test_process_issues_empty() {
562 let config = BudgetConfig::default();
563 let result = process_issues(&[], TrimStrategyKind::Default, &config).unwrap();
564 assert!(!result.trimmed);
565 assert_eq!(result.total_items, 0);
566 }
567
568 fn sample_merge_requests(n: usize) -> Vec<devboy_core::MergeRequest> {
571 (0..n)
572 .map(|i| devboy_core::MergeRequest {
573 key: format!("mr#{}", i + 1),
574 title: format!("Merge Request {}", i + 1),
575 description: Some(format!(
576 "Description for MR {} with enough text to make it non-trivial for token counting purposes",
577 i + 1
578 )),
579 state: "opened".into(),
580 source: "gitlab".into(),
581 source_branch: format!("feature-{}", i + 1),
582 target_branch: "main".into(),
583 author: Some(devboy_core::User {
584 id: format!("{}", i),
585 username: format!("user{}", i),
586 name: None,
587 email: None,
588 avatar_url: None,
589 }),
590 assignees: vec![],
591 reviewers: vec![],
592 labels: vec!["enhancement".into()],
593 draft: false,
594 url: Some(format!("https://gitlab.com/test/repo/-/merge_requests/{}", i + 1)),
595 created_at: Some("2024-01-01T00:00:00Z".into()),
596 updated_at: Some("2024-01-02T00:00:00Z".into()),
597 })
598 .collect()
599 }
600
601 #[test]
602 fn test_process_merge_requests_fast_path() {
603 let mrs = sample_merge_requests(2);
604 let config = BudgetConfig {
605 budget_tokens: 50000,
606 ..Default::default()
607 };
608 let result = process_merge_requests(&mrs, TrimStrategyKind::ElementCount, &config).unwrap();
609 assert!(!result.trimmed);
610 assert!(!result.content.is_empty());
611 assert_eq!(result.total_items, 2);
612 assert_eq!(result.included_items, 2);
613 }
614
615 #[test]
616 fn test_process_merge_requests_with_trimming() {
617 let mrs = sample_merge_requests(20);
618 let config = BudgetConfig {
619 budget_tokens: 500,
620 margin: 0.20,
621 max_iterations: 3,
622 };
623 let result = process_merge_requests(&mrs, TrimStrategyKind::ElementCount, &config).unwrap();
624 assert!(result.trimmed);
625 assert!(result.included_items < 20);
626 assert!(!result.content.is_empty());
627 assert!(result.tokens <= config.budget_tokens);
628 }
629
630 #[test]
631 fn test_process_merge_requests_empty() {
632 let config = BudgetConfig::default();
633 let result = process_merge_requests(&[], TrimStrategyKind::Default, &config).unwrap();
634 assert!(!result.trimmed);
635 assert_eq!(result.total_items, 0);
636 }
637
638 fn sample_diffs(n: usize) -> Vec<devboy_core::FileDiff> {
641 (0..n)
642 .map(|i| devboy_core::FileDiff {
643 file_path: format!("src/module_{}/file_{}.rs", i / 3, i + 1),
644 old_path: None,
645 new_file: i == 0,
646 deleted_file: false,
647 renamed_file: false,
648 diff: format!(
649 "@@ -1,10 +1,15 @@\n-old line {i}\n+new line {i}\n+added context for file {i} with enough diff content to be meaningful",
650 i = i + 1
651 ),
652 additions: Some(2),
653 deletions: Some(1),
654 })
655 .collect()
656 }
657
658 #[test]
659 fn test_process_diffs_fast_path() {
660 let diffs = sample_diffs(2);
661 let config = BudgetConfig {
662 budget_tokens: 50000,
663 ..Default::default()
664 };
665 let result = process_diffs(&diffs, TrimStrategyKind::ElementCount, &config).unwrap();
666 assert!(!result.trimmed);
667 assert!(!result.content.is_empty());
668 assert_eq!(result.total_items, 2);
669 assert_eq!(result.included_items, 2);
670 }
671
672 #[test]
673 fn test_process_diffs_with_trimming() {
674 let diffs = sample_diffs(20);
675 let config = BudgetConfig {
676 budget_tokens: 200,
677 margin: 0.20,
678 max_iterations: 3,
679 };
680 let result = process_diffs(&diffs, TrimStrategyKind::ElementCount, &config).unwrap();
681 assert!(result.trimmed);
682 assert!(result.included_items < 20);
683 assert!(!result.content.is_empty());
684 }
685
686 #[test]
687 fn test_process_diffs_empty() {
688 let config = BudgetConfig::default();
689 let result = process_diffs(&[], TrimStrategyKind::Default, &config).unwrap();
690 assert!(!result.trimmed);
691 assert_eq!(result.total_items, 0);
692 }
693
694 fn sample_discussions(n: usize) -> Vec<devboy_core::Discussion> {
697 (0..n)
698 .map(|i| devboy_core::Discussion {
699 id: format!("disc-{}", i + 1),
700 resolved: i % 3 == 0,
701 resolved_by: None,
702 comments: vec![devboy_core::Comment {
703 id: format!("comment-{}", i + 1),
704 body: format!(
705 "Discussion comment {} with enough body text for token counting purposes in the budget pipeline",
706 i + 1
707 ),
708 author: Some(devboy_core::User {
709 id: format!("{}", i),
710 username: format!("reviewer{}", i),
711 name: None,
712 email: None,
713 avatar_url: None,
714 }),
715 created_at: Some("2024-01-01T00:00:00Z".into()),
716 updated_at: None,
717 position: None,
718 }],
719 position: None,
720 })
721 .collect()
722 }
723
724 #[test]
725 fn test_process_discussions_fast_path() {
726 let discussions = sample_discussions(2);
727 let config = BudgetConfig {
728 budget_tokens: 50000,
729 ..Default::default()
730 };
731 let result =
732 process_discussions(&discussions, TrimStrategyKind::ElementCount, &config).unwrap();
733 assert!(!result.trimmed);
734 assert!(!result.content.is_empty());
735 assert_eq!(result.total_items, 2);
736 assert_eq!(result.included_items, 2);
737 }
738
739 #[test]
740 fn test_process_discussions_with_trimming() {
741 let discussions = sample_discussions(20);
742 let config = BudgetConfig {
743 budget_tokens: 300,
744 margin: 0.20,
745 max_iterations: 3,
746 };
747 let result =
748 process_discussions(&discussions, TrimStrategyKind::ElementCount, &config).unwrap();
749 assert!(result.trimmed);
750 assert!(result.included_items < 20);
751 assert!(!result.content.is_empty());
752 }
753
754 #[test]
755 fn test_process_discussions_empty() {
756 let config = BudgetConfig::default();
757 let result = process_discussions(&[], TrimStrategyKind::Default, &config).unwrap();
758 assert!(!result.trimmed);
759 assert_eq!(result.total_items, 0);
760 }
761
762 fn sample_comments(n: usize) -> Vec<devboy_core::Comment> {
765 (0..n)
766 .map(|i| devboy_core::Comment {
767 id: format!("c-{}", i + 1),
768 body: format!(
769 "Comment {} with enough body text to make it non-trivial for budget pipeline token counting",
770 i + 1
771 ),
772 author: Some(devboy_core::User {
773 id: format!("{}", i),
774 username: format!("commenter{}", i),
775 name: None,
776 email: None,
777 avatar_url: None,
778 }),
779 created_at: Some("2024-01-01T00:00:00Z".into()),
780 updated_at: Some("2024-01-02T00:00:00Z".into()),
781 position: None,
782 })
783 .collect()
784 }
785
786 #[test]
787 fn test_process_comments_fast_path() {
788 let comments = sample_comments(2);
789 let config = BudgetConfig {
790 budget_tokens: 50000,
791 ..Default::default()
792 };
793 let result = process_comments(&comments, TrimStrategyKind::ElementCount, &config).unwrap();
794 assert!(!result.trimmed);
795 assert!(!result.content.is_empty());
796 assert_eq!(result.total_items, 2);
797 assert_eq!(result.included_items, 2);
798 }
799
800 #[test]
801 fn test_process_comments_with_trimming() {
802 let comments = sample_comments(20);
803 let config = BudgetConfig {
804 budget_tokens: 300,
805 margin: 0.20,
806 max_iterations: 3,
807 };
808 let result = process_comments(&comments, TrimStrategyKind::ElementCount, &config).unwrap();
809 assert!(result.trimmed);
810 assert!(result.included_items < 20);
811 assert!(!result.content.is_empty());
812 }
813
814 #[test]
815 fn test_process_comments_empty() {
816 let config = BudgetConfig::default();
817 let result = process_comments(&[], TrimStrategyKind::Default, &config).unwrap();
818 assert!(!result.trimmed);
819 assert_eq!(result.total_items, 0);
820 }
821}