codetether_agent/session/helper/
text.rs1use crate::provider::{ContentPart, Message, Role};
2use regex::Regex;
3use std::path::Path;
4use std::sync::OnceLock;
5
6pub fn role_label(role: Role) -> &'static str {
7 match role {
8 Role::System => "System",
9 Role::User => "User",
10 Role::Assistant => "Assistant",
11 Role::Tool => "Tool",
12 }
13}
14pub fn extract_text_content(parts: &[ContentPart]) -> String {
16 parts
17 .iter()
18 .filter_map(|part| match part {
19 ContentPart::Text { text } => Some(text.as_str()),
20 _ => None,
21 })
22 .collect::<Vec<_>>()
23 .join("\n")
24}
25
26pub fn latest_user_text(messages: &[Message]) -> Option<String> {
28 messages.iter().rev().find_map(|m| {
29 if m.role != Role::User {
30 return None;
31 }
32 let text = extract_text_content(&m.content);
33 if text.trim().is_empty() {
34 None
35 } else {
36 Some(text)
37 }
38 })
39}
40
41pub fn extract_candidate_file_paths(text: &str, cwd: &Path, max_files: usize) -> Vec<String> {
43 static FILE_PATH_RE: OnceLock<Regex> = OnceLock::new();
44 let re = FILE_PATH_RE
45 .get_or_init(|| Regex::new(r"(?P<path>[a-zA-Z0-9_\-\./]+\.[a-zA-Z0-9]+)").unwrap());
46
47 let mut out = Vec::new();
48 for cap in re.captures_iter(text) {
49 let Some(path) = cap.name("path").map(|m| m.as_str()) else {
50 continue;
51 };
52 let path_str = path.to_string();
53 if path_str.is_empty() || out.iter().any(|p: &String| p == &path_str) {
54 continue;
55 }
56 if cwd.join(&path_str).exists() {
57 out.push(path_str);
58 }
59 if out.len() >= max_files {
60 break;
61 }
62 }
63 out
64}
65
66pub fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
68 if max_chars == 0 {
69 return String::new();
70 }
71
72 let mut chars = value.chars();
73 let mut output = String::new();
74 for _ in 0..max_chars {
75 if let Some(c) = chars.next() {
76 output.push(c);
77 } else {
78 break;
79 }
80 }
81
82 if chars.next().is_some() {
83 format!("{output}...")
84 } else {
85 output
86 }
87}