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 ..Default::default()
501 })
502 .collect()
503 }
504
505 #[test]
506 fn test_process_issues_fast_path() {
507 let issues = sample_issues(2);
508 let config = BudgetConfig {
509 budget_tokens: 50000,
510 ..Default::default()
511 };
512 let result = process_issues(&issues, TrimStrategyKind::ElementCount, &config).unwrap();
513 assert!(!result.trimmed);
514 assert!(!result.content.is_empty());
515 assert_eq!(result.total_items, 2);
516 assert_eq!(result.included_items, 2);
517 }
518
519 #[test]
520 fn test_process_issues_with_trimming() {
521 let issues = sample_issues(20);
522 let config = BudgetConfig {
523 budget_tokens: 500,
524 margin: 0.20,
525 max_iterations: 3,
526 };
527 let result = process_issues(&issues, TrimStrategyKind::ElementCount, &config).unwrap();
528 assert!(result.trimmed);
529 assert!(result.included_items < 20);
530 assert!(!result.content.is_empty());
531 assert!(result.tokens <= config.budget_tokens);
532 }
533
534 #[test]
535 fn test_process_issues_with_cascading_strategy() {
536 let issues = sample_issues(10);
537 let config = BudgetConfig {
538 budget_tokens: 300,
539 margin: 0.20,
540 max_iterations: 3,
541 };
542 let result = process_issues(&issues, TrimStrategyKind::Cascading, &config).unwrap();
543 assert!(result.trimmed);
544 assert!(!result.content.is_empty());
545 }
546
547 #[test]
548 fn test_process_issues_very_small_budget() {
549 let issues = sample_issues(10);
550 let config = BudgetConfig {
551 budget_tokens: 50,
552 margin: 0.20,
553 max_iterations: 3,
554 };
555 let result = process_issues(&issues, TrimStrategyKind::Default, &config).unwrap();
556 assert!(result.trimmed);
557 assert!(!result.content.is_empty());
559 }
560
561 #[test]
562 fn test_process_issues_empty() {
563 let config = BudgetConfig::default();
564 let result = process_issues(&[], TrimStrategyKind::Default, &config).unwrap();
565 assert!(!result.trimmed);
566 assert_eq!(result.total_items, 0);
567 }
568
569 fn sample_merge_requests(n: usize) -> Vec<devboy_core::MergeRequest> {
572 (0..n)
573 .map(|i| devboy_core::MergeRequest {
574 key: format!("mr#{}", i + 1),
575 title: format!("Merge Request {}", i + 1),
576 description: Some(format!(
577 "Description for MR {} with enough text to make it non-trivial for token counting purposes",
578 i + 1
579 )),
580 state: "opened".into(),
581 source: "gitlab".into(),
582 source_branch: format!("feature-{}", i + 1),
583 target_branch: "main".into(),
584 author: Some(devboy_core::User {
585 id: format!("{}", i),
586 username: format!("user{}", i),
587 name: None,
588 email: None,
589 avatar_url: None,
590 }),
591 assignees: vec![],
592 reviewers: vec![],
593 labels: vec!["enhancement".into()],
594 draft: false,
595 url: Some(format!("https://gitlab.com/test/repo/-/merge_requests/{}", i + 1)),
596 created_at: Some("2024-01-01T00:00:00Z".into()),
597 updated_at: Some("2024-01-02T00:00:00Z".into()),
598 })
599 .collect()
600 }
601
602 #[test]
603 fn test_process_merge_requests_fast_path() {
604 let mrs = sample_merge_requests(2);
605 let config = BudgetConfig {
606 budget_tokens: 50000,
607 ..Default::default()
608 };
609 let result = process_merge_requests(&mrs, TrimStrategyKind::ElementCount, &config).unwrap();
610 assert!(!result.trimmed);
611 assert!(!result.content.is_empty());
612 assert_eq!(result.total_items, 2);
613 assert_eq!(result.included_items, 2);
614 }
615
616 #[test]
617 fn test_process_merge_requests_with_trimming() {
618 let mrs = sample_merge_requests(20);
619 let config = BudgetConfig {
620 budget_tokens: 500,
621 margin: 0.20,
622 max_iterations: 3,
623 };
624 let result = process_merge_requests(&mrs, TrimStrategyKind::ElementCount, &config).unwrap();
625 assert!(result.trimmed);
626 assert!(result.included_items < 20);
627 assert!(!result.content.is_empty());
628 assert!(result.tokens <= config.budget_tokens);
629 }
630
631 #[test]
632 fn test_process_merge_requests_empty() {
633 let config = BudgetConfig::default();
634 let result = process_merge_requests(&[], TrimStrategyKind::Default, &config).unwrap();
635 assert!(!result.trimmed);
636 assert_eq!(result.total_items, 0);
637 }
638
639 fn sample_diffs(n: usize) -> Vec<devboy_core::FileDiff> {
642 (0..n)
643 .map(|i| devboy_core::FileDiff {
644 file_path: format!("src/module_{}/file_{}.rs", i / 3, i + 1),
645 old_path: None,
646 new_file: i == 0,
647 deleted_file: false,
648 renamed_file: false,
649 diff: format!(
650 "@@ -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",
651 i = i + 1
652 ),
653 additions: Some(2),
654 deletions: Some(1),
655 })
656 .collect()
657 }
658
659 #[test]
660 fn test_process_diffs_fast_path() {
661 let diffs = sample_diffs(2);
662 let config = BudgetConfig {
663 budget_tokens: 50000,
664 ..Default::default()
665 };
666 let result = process_diffs(&diffs, TrimStrategyKind::ElementCount, &config).unwrap();
667 assert!(!result.trimmed);
668 assert!(!result.content.is_empty());
669 assert_eq!(result.total_items, 2);
670 assert_eq!(result.included_items, 2);
671 }
672
673 #[test]
674 fn test_process_diffs_with_trimming() {
675 let diffs = sample_diffs(20);
676 let config = BudgetConfig {
677 budget_tokens: 200,
678 margin: 0.20,
679 max_iterations: 3,
680 };
681 let result = process_diffs(&diffs, TrimStrategyKind::ElementCount, &config).unwrap();
682 assert!(result.trimmed);
683 assert!(result.included_items < 20);
684 assert!(!result.content.is_empty());
685 }
686
687 #[test]
688 fn test_process_diffs_empty() {
689 let config = BudgetConfig::default();
690 let result = process_diffs(&[], TrimStrategyKind::Default, &config).unwrap();
691 assert!(!result.trimmed);
692 assert_eq!(result.total_items, 0);
693 }
694
695 fn sample_discussions(n: usize) -> Vec<devboy_core::Discussion> {
698 (0..n)
699 .map(|i| devboy_core::Discussion {
700 id: format!("disc-{}", i + 1),
701 resolved: i % 3 == 0,
702 resolved_by: None,
703 comments: vec![devboy_core::Comment {
704 id: format!("comment-{}", i + 1),
705 body: format!(
706 "Discussion comment {} with enough body text for token counting purposes in the budget pipeline",
707 i + 1
708 ),
709 author: Some(devboy_core::User {
710 id: format!("{}", i),
711 username: format!("reviewer{}", i),
712 name: None,
713 email: None,
714 avatar_url: None,
715 }),
716 created_at: Some("2024-01-01T00:00:00Z".into()),
717 updated_at: None,
718 position: None,
719 }],
720 position: None,
721 })
722 .collect()
723 }
724
725 #[test]
726 fn test_process_discussions_fast_path() {
727 let discussions = sample_discussions(2);
728 let config = BudgetConfig {
729 budget_tokens: 50000,
730 ..Default::default()
731 };
732 let result =
733 process_discussions(&discussions, TrimStrategyKind::ElementCount, &config).unwrap();
734 assert!(!result.trimmed);
735 assert!(!result.content.is_empty());
736 assert_eq!(result.total_items, 2);
737 assert_eq!(result.included_items, 2);
738 }
739
740 #[test]
741 fn test_process_discussions_with_trimming() {
742 let discussions = sample_discussions(20);
743 let config = BudgetConfig {
744 budget_tokens: 300,
745 margin: 0.20,
746 max_iterations: 3,
747 };
748 let result =
749 process_discussions(&discussions, TrimStrategyKind::ElementCount, &config).unwrap();
750 assert!(result.trimmed);
751 assert!(result.included_items < 20);
752 assert!(!result.content.is_empty());
753 }
754
755 #[test]
756 fn test_process_discussions_empty() {
757 let config = BudgetConfig::default();
758 let result = process_discussions(&[], TrimStrategyKind::Default, &config).unwrap();
759 assert!(!result.trimmed);
760 assert_eq!(result.total_items, 0);
761 }
762
763 fn sample_comments(n: usize) -> Vec<devboy_core::Comment> {
766 (0..n)
767 .map(|i| devboy_core::Comment {
768 id: format!("c-{}", i + 1),
769 body: format!(
770 "Comment {} with enough body text to make it non-trivial for budget pipeline token counting",
771 i + 1
772 ),
773 author: Some(devboy_core::User {
774 id: format!("{}", i),
775 username: format!("commenter{}", i),
776 name: None,
777 email: None,
778 avatar_url: None,
779 }),
780 created_at: Some("2024-01-01T00:00:00Z".into()),
781 updated_at: Some("2024-01-02T00:00:00Z".into()),
782 position: None,
783 })
784 .collect()
785 }
786
787 #[test]
788 fn test_process_comments_fast_path() {
789 let comments = sample_comments(2);
790 let config = BudgetConfig {
791 budget_tokens: 50000,
792 ..Default::default()
793 };
794 let result = process_comments(&comments, TrimStrategyKind::ElementCount, &config).unwrap();
795 assert!(!result.trimmed);
796 assert!(!result.content.is_empty());
797 assert_eq!(result.total_items, 2);
798 assert_eq!(result.included_items, 2);
799 }
800
801 #[test]
802 fn test_process_comments_with_trimming() {
803 let comments = sample_comments(20);
804 let config = BudgetConfig {
805 budget_tokens: 300,
806 margin: 0.20,
807 max_iterations: 3,
808 };
809 let result = process_comments(&comments, TrimStrategyKind::ElementCount, &config).unwrap();
810 assert!(result.trimmed);
811 assert!(result.included_items < 20);
812 assert!(!result.content.is_empty());
813 }
814
815 #[test]
816 fn test_process_comments_empty() {
817 let config = BudgetConfig::default();
818 let result = process_comments(&[], TrimStrategyKind::Default, &config).unwrap();
819 assert!(!result.trimmed);
820 assert_eq!(result.total_items, 0);
821 }
822}