1use crate::agent::ToolUse;
6use crate::provider::{Message, Usage};
7use crate::tool::ToolRegistry;
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12use std::sync::Arc;
13use tokio::fs;
14use uuid::Uuid;
15
16#[cfg(feature = "functiongemma")]
17use crate::cognition::tool_router::{ToolCallRouter, ToolRouterConfig};
18
19fn is_interactive_tool(tool_name: &str) -> bool {
20 matches!(tool_name, "question")
21}
22
23fn choose_default_provider<'a>(providers: &'a [&'a str]) -> Option<&'a str> {
24 let preferred = [
27 "zhipuai",
28 "openai",
29 "github-copilot",
30 "anthropic",
31 "openrouter",
32 "novita",
33 "moonshotai",
34 "google",
35 ];
36 for name in preferred {
37 if let Some(found) = providers.iter().copied().find(|p| *p == name) {
38 return Some(found);
39 }
40 }
41 providers.first().copied()
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Session {
47 pub id: String,
48 pub title: Option<String>,
49 pub created_at: DateTime<Utc>,
50 pub updated_at: DateTime<Utc>,
51 pub messages: Vec<Message>,
52 pub tool_uses: Vec<ToolUse>,
53 pub usage: Usage,
54 pub agent: String,
55 pub metadata: SessionMetadata,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct SessionMetadata {
60 pub directory: Option<PathBuf>,
61 pub model: Option<String>,
62 pub shared: bool,
63 pub share_url: Option<String>,
64}
65
66impl Session {
67 fn default_model_for_provider(provider: &str) -> String {
68 match provider {
69 "moonshotai" => "kimi-k2.5".to_string(),
70 "anthropic" => "claude-sonnet-4-20250514".to_string(),
71 "openai" => "gpt-4o".to_string(),
72 "google" => "gemini-2.5-pro".to_string(),
73 "zhipuai" => "glm-4.7".to_string(),
74 "openrouter" => "z-ai/glm-4.7".to_string(),
75 "novita" => "qwen/qwen3-coder-next".to_string(),
76 "github-copilot" | "github-copilot-enterprise" => "gpt-5-mini".to_string(),
77 _ => "glm-4.7".to_string(),
78 }
79 }
80
81 pub async fn new() -> Result<Self> {
83 let id = Uuid::new_v4().to_string();
84 let now = Utc::now();
85
86 Ok(Self {
87 id,
88 title: None,
89 created_at: now,
90 updated_at: now,
91 messages: Vec::new(),
92 tool_uses: Vec::new(),
93 usage: Usage::default(),
94 agent: "build".to_string(),
95 metadata: SessionMetadata {
96 directory: Some(std::env::current_dir()?),
97 ..Default::default()
98 },
99 })
100 }
101
102 pub async fn load(id: &str) -> Result<Self> {
104 let path = Self::session_path(id)?;
105 let content = fs::read_to_string(&path).await?;
106 let session: Session = serde_json::from_str(&content)?;
107 Ok(session)
108 }
109
110 pub async fn last_for_directory(workspace: Option<&std::path::Path>) -> Result<Self> {
115 let sessions_dir = Self::sessions_dir()?;
116
117 if !sessions_dir.exists() {
118 anyhow::bail!("No sessions found");
119 }
120
121 let mut entries: Vec<tokio::fs::DirEntry> = Vec::new();
122 let mut read_dir = fs::read_dir(&sessions_dir).await?;
123 while let Some(entry) = read_dir.next_entry().await? {
124 entries.push(entry);
125 }
126
127 if entries.is_empty() {
128 anyhow::bail!("No sessions found");
129 }
130
131 entries.sort_by_key(|e| {
134 std::cmp::Reverse(
135 std::fs::metadata(e.path())
136 .ok()
137 .and_then(|m| m.modified().ok())
138 .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
139 )
140 });
141
142 let canonical_workspace =
143 workspace.map(|w| w.canonicalize().unwrap_or_else(|_| w.to_path_buf()));
144
145 for entry in &entries {
146 let content: String = fs::read_to_string(entry.path()).await?;
147 if let Ok(session) = serde_json::from_str::<Session>(&content) {
148 if let Some(ref ws) = canonical_workspace {
150 if let Some(ref dir) = session.metadata.directory {
151 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
152 if &canonical_dir == ws {
153 return Ok(session);
154 }
155 }
156 continue;
157 }
158 return Ok(session);
159 }
160 }
161
162 anyhow::bail!("No sessions found")
163 }
164
165 pub async fn last() -> Result<Self> {
167 Self::last_for_directory(None).await
168 }
169
170 pub async fn save(&self) -> Result<()> {
172 let path = Self::session_path(&self.id)?;
173
174 if let Some(parent) = path.parent() {
175 fs::create_dir_all(parent).await?;
176 }
177
178 let content = serde_json::to_string_pretty(self)?;
179 fs::write(&path, content).await?;
180
181 Ok(())
182 }
183
184 pub fn add_message(&mut self, message: Message) {
186 self.messages.push(message);
187 self.updated_at = Utc::now();
188 }
189
190 pub async fn prompt(&mut self, message: &str) -> Result<SessionResult> {
192 use crate::provider::{
193 CompletionRequest, ContentPart, ProviderRegistry, Role, parse_model_string,
194 };
195
196 let registry = ProviderRegistry::from_vault().await?;
198
199 let providers = registry.list();
200 if providers.is_empty() {
201 anyhow::bail!(
202 "No providers available. Configure API keys in HashiCorp Vault (for Copilot use `codetether auth copilot`)."
203 );
204 }
205
206 tracing::info!("Available providers: {:?}", providers);
207
208 let (provider_name, model_id) = if let Some(ref model_str) = self.metadata.model {
210 let (prov, model) = parse_model_string(model_str);
211 if prov.is_some() {
212 (prov.map(|s| s.to_string()), model.to_string())
214 } else if providers.contains(&model) {
215 (Some(model.to_string()), String::new())
217 } else {
218 (None, model.to_string())
220 }
221 } else {
222 (None, String::new())
223 };
224
225 let selected_provider = provider_name
227 .as_deref()
228 .filter(|p| providers.contains(p))
229 .or_else(|| choose_default_provider(providers.as_slice()))
230 .ok_or_else(|| anyhow::anyhow!("No providers available"))?;
231
232 let provider = registry
233 .get(selected_provider)
234 .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected_provider))?;
235
236 self.add_message(Message {
238 role: Role::User,
239 content: vec![ContentPart::Text {
240 text: message.to_string(),
241 }],
242 });
243
244 if self.title.is_none() {
246 self.generate_title().await?;
247 }
248
249 let model = if !model_id.is_empty() {
251 model_id
252 } else {
253 Self::default_model_for_provider(selected_provider)
254 };
255
256 let tool_registry = ToolRegistry::with_provider_arc(Arc::clone(&provider), model.clone());
258 let tool_definitions: Vec<_> = tool_registry
259 .definitions()
260 .into_iter()
261 .filter(|tool| !is_interactive_tool(&tool.name))
262 .collect();
263
264 let temperature = if model.starts_with("kimi-k2") {
266 Some(1.0)
267 } else {
268 Some(0.7)
269 };
270
271 tracing::info!("Using model: {} via provider: {}", model, selected_provider);
272 tracing::info!("Available tools: {}", tool_definitions.len());
273
274 #[cfg(feature = "functiongemma")]
277 let model_supports_tools = provider
278 .list_models()
279 .await
280 .unwrap_or_default()
281 .iter()
282 .find(|m| m.id == model)
283 .map(|m| m.supports_tools)
284 .unwrap_or(true); let cwd = self
288 .metadata
289 .directory
290 .clone()
291 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
292 let system_prompt = crate::agent::builtin::build_system_prompt(&cwd);
293
294 let max_steps = 50;
296 let mut final_output = String::new();
297
298 #[cfg(feature = "functiongemma")]
300 let tool_router: Option<ToolCallRouter> = {
301 let cfg = ToolRouterConfig::from_env();
302 match ToolCallRouter::from_config(&cfg) {
303 Ok(r) => r,
304 Err(e) => {
305 tracing::warn!(error = %e, "FunctionGemma tool router init failed; disabled");
306 None
307 }
308 }
309 };
310
311 for step in 1..=max_steps {
312 tracing::info!(step = step, "Agent step starting");
313
314 let mut messages = vec![Message {
316 role: Role::System,
317 content: vec![ContentPart::Text {
318 text: system_prompt.clone(),
319 }],
320 }];
321 messages.extend(self.messages.clone());
322
323 let request = CompletionRequest {
325 messages,
326 tools: tool_definitions.clone(),
327 model: model.clone(),
328 temperature,
329 top_p: None,
330 max_tokens: Some(8192),
331 stop: Vec::new(),
332 };
333
334 let response = provider.complete(request).await?;
336
337 #[cfg(feature = "functiongemma")]
341 let response = if let Some(ref router) = tool_router {
342 router
343 .maybe_reformat(response, &tool_definitions, model_supports_tools)
344 .await
345 } else {
346 response
347 };
348
349 crate::telemetry::TOKEN_USAGE.record_model_usage(
351 &model,
352 response.usage.prompt_tokens as u64,
353 response.usage.completion_tokens as u64,
354 );
355
356 let tool_calls: Vec<(String, String, serde_json::Value)> = response
358 .message
359 .content
360 .iter()
361 .filter_map(|part| {
362 if let ContentPart::ToolCall {
363 id,
364 name,
365 arguments,
366 } = part
367 {
368 let args: serde_json::Value =
370 serde_json::from_str(arguments).unwrap_or(serde_json::json!({}));
371 Some((id.clone(), name.clone(), args))
372 } else {
373 None
374 }
375 })
376 .collect();
377
378 for part in &response.message.content {
380 if let ContentPart::Text { text } = part {
381 if !text.is_empty() {
382 final_output.push_str(text);
383 final_output.push('\n');
384 }
385 }
386 }
387
388 if tool_calls.is_empty() {
390 self.add_message(response.message.clone());
391 break;
392 }
393
394 self.add_message(response.message.clone());
396
397 tracing::info!(
398 step = step,
399 num_tools = tool_calls.len(),
400 "Executing tool calls"
401 );
402
403 for (tool_id, tool_name, tool_input) in tool_calls {
405 tracing::info!(tool = %tool_name, tool_id = %tool_id, "Executing tool");
406
407 if is_interactive_tool(&tool_name) {
408 tracing::warn!(tool = %tool_name, "Blocking interactive tool in session loop");
409 self.add_message(Message {
410 role: Role::Tool,
411 content: vec![ContentPart::ToolResult {
412 tool_call_id: tool_id,
413 content: "Error: Interactive tool 'question' is disabled in this interface. Ask the user directly in assistant text.".to_string(),
414 }],
415 });
416 continue;
417 }
418
419 let content = if let Some(tool) = tool_registry.get(&tool_name) {
421 match tool.execute(tool_input.clone()).await {
422 Ok(result) => {
423 tracing::info!(tool = %tool_name, success = result.success, "Tool execution completed");
424 result.output
425 }
426 Err(e) => {
427 tracing::warn!(tool = %tool_name, error = %e, "Tool execution failed");
428 format!("Error: {}", e)
429 }
430 }
431 } else {
432 tracing::warn!(tool = %tool_name, "Tool not found");
433 format!("Error: Unknown tool '{}'", tool_name)
434 };
435
436 self.add_message(Message {
438 role: Role::Tool,
439 content: vec![ContentPart::ToolResult {
440 tool_call_id: tool_id,
441 content,
442 }],
443 });
444 }
445 }
446
447 self.save().await?;
449
450 Ok(SessionResult {
451 text: final_output.trim().to_string(),
452 session_id: self.id.clone(),
453 })
454 }
455
456 pub async fn prompt_with_events(
459 &mut self,
460 message: &str,
461 event_tx: tokio::sync::mpsc::Sender<SessionEvent>,
462 ) -> Result<SessionResult> {
463 use crate::provider::{
464 CompletionRequest, ContentPart, ProviderRegistry, Role, parse_model_string,
465 };
466
467 let _ = event_tx.send(SessionEvent::Thinking).await;
468
469 let registry = ProviderRegistry::from_vault().await?;
471 let providers = registry.list();
472 if providers.is_empty() {
473 anyhow::bail!(
474 "No providers available. Configure API keys in HashiCorp Vault (for Copilot use `codetether auth copilot`)."
475 );
476 }
477 tracing::info!("Available providers: {:?}", providers);
478
479 let (provider_name, model_id) = if let Some(ref model_str) = self.metadata.model {
481 let (prov, model) = parse_model_string(model_str);
482 if prov.is_some() {
483 (prov.map(|s| s.to_string()), model.to_string())
484 } else if providers.contains(&model) {
485 (Some(model.to_string()), String::new())
486 } else {
487 (None, model.to_string())
488 }
489 } else {
490 (None, String::new())
491 };
492
493 let selected_provider = provider_name
495 .as_deref()
496 .filter(|p| providers.contains(p))
497 .or_else(|| choose_default_provider(providers.as_slice()))
498 .ok_or_else(|| anyhow::anyhow!("No providers available"))?;
499
500 let provider = registry
501 .get(selected_provider)
502 .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected_provider))?;
503
504 self.add_message(Message {
506 role: Role::User,
507 content: vec![ContentPart::Text {
508 text: message.to_string(),
509 }],
510 });
511
512 if self.title.is_none() {
514 self.generate_title().await?;
515 }
516
517 let model = if !model_id.is_empty() {
519 model_id
520 } else {
521 Self::default_model_for_provider(selected_provider)
522 };
523
524 let tool_registry = ToolRegistry::with_provider_arc(Arc::clone(&provider), model.clone());
526 let tool_definitions: Vec<_> = tool_registry
527 .definitions()
528 .into_iter()
529 .filter(|tool| !is_interactive_tool(&tool.name))
530 .collect();
531
532 let temperature = if model.starts_with("kimi-k2") {
533 Some(1.0)
534 } else {
535 Some(0.7)
536 };
537
538 tracing::info!("Using model: {} via provider: {}", model, selected_provider);
539 tracing::info!("Available tools: {}", tool_definitions.len());
540
541 #[cfg(feature = "functiongemma")]
543 let model_supports_tools = provider
544 .list_models()
545 .await
546 .unwrap_or_default()
547 .iter()
548 .find(|m| m.id == model)
549 .map(|m| m.supports_tools)
550 .unwrap_or(true);
551
552 let cwd = std::env::var("PWD")
554 .map(std::path::PathBuf::from)
555 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
556 let system_prompt = crate::agent::builtin::build_system_prompt(&cwd);
557
558 let mut final_output = String::new();
559 let max_steps = 50;
560
561 #[cfg(feature = "functiongemma")]
563 let tool_router: Option<ToolCallRouter> = {
564 let cfg = ToolRouterConfig::from_env();
565 match ToolCallRouter::from_config(&cfg) {
566 Ok(r) => r,
567 Err(e) => {
568 tracing::warn!(error = %e, "FunctionGemma tool router init failed; disabled");
569 None
570 }
571 }
572 };
573
574 for step in 1..=max_steps {
575 tracing::info!(step = step, "Agent step starting");
576 let _ = event_tx.send(SessionEvent::Thinking).await;
577
578 let mut messages = vec![Message {
580 role: Role::System,
581 content: vec![ContentPart::Text {
582 text: system_prompt.clone(),
583 }],
584 }];
585 messages.extend(self.messages.clone());
586
587 let request = CompletionRequest {
588 messages,
589 tools: tool_definitions.clone(),
590 model: model.clone(),
591 temperature,
592 top_p: None,
593 max_tokens: Some(8192),
594 stop: Vec::new(),
595 };
596
597 let response = provider.complete(request).await?;
598
599 #[cfg(feature = "functiongemma")]
602 let response = if let Some(ref router) = tool_router {
603 router
604 .maybe_reformat(response, &tool_definitions, model_supports_tools)
605 .await
606 } else {
607 response
608 };
609
610 crate::telemetry::TOKEN_USAGE.record_model_usage(
611 &model,
612 response.usage.prompt_tokens as u64,
613 response.usage.completion_tokens as u64,
614 );
615
616 let tool_calls: Vec<(String, String, serde_json::Value)> = response
618 .message
619 .content
620 .iter()
621 .filter_map(|part| {
622 if let ContentPart::ToolCall {
623 id,
624 name,
625 arguments,
626 } = part
627 {
628 let args: serde_json::Value =
629 serde_json::from_str(arguments).unwrap_or(serde_json::json!({}));
630 Some((id.clone(), name.clone(), args))
631 } else {
632 None
633 }
634 })
635 .collect();
636
637 for part in &response.message.content {
639 if let ContentPart::Text { text } = part {
640 if !text.is_empty() {
641 final_output.push_str(text);
642 final_output.push('\n');
643 let _ = event_tx.send(SessionEvent::TextChunk(text.clone())).await;
644 }
645 }
646 }
647
648 if tool_calls.is_empty() {
649 self.add_message(response.message.clone());
650 break;
651 }
652
653 self.add_message(response.message.clone());
654
655 tracing::info!(
656 step = step,
657 num_tools = tool_calls.len(),
658 "Executing tool calls"
659 );
660
661 for (tool_id, tool_name, tool_input) in tool_calls {
663 let args_str = serde_json::to_string(&tool_input).unwrap_or_default();
664 let _ = event_tx
665 .send(SessionEvent::ToolCallStart {
666 name: tool_name.clone(),
667 arguments: args_str,
668 })
669 .await;
670
671 tracing::info!(tool = %tool_name, tool_id = %tool_id, "Executing tool");
672
673 if is_interactive_tool(&tool_name) {
674 tracing::warn!(tool = %tool_name, "Blocking interactive tool in session loop");
675 let content = "Error: Interactive tool 'question' is disabled in this interface. Ask the user directly in assistant text.".to_string();
676 let _ = event_tx
677 .send(SessionEvent::ToolCallComplete {
678 name: tool_name.clone(),
679 output: content.clone(),
680 success: false,
681 })
682 .await;
683 self.add_message(Message {
684 role: Role::Tool,
685 content: vec![ContentPart::ToolResult {
686 tool_call_id: tool_id,
687 content,
688 }],
689 });
690 continue;
691 }
692
693 let (content, success) = if let Some(tool) = tool_registry.get(&tool_name) {
694 match tool.execute(tool_input.clone()).await {
695 Ok(result) => {
696 tracing::info!(tool = %tool_name, success = result.success, "Tool execution completed");
697 (result.output, result.success)
698 }
699 Err(e) => {
700 tracing::warn!(tool = %tool_name, error = %e, "Tool execution failed");
701 (format!("Error: {}", e), false)
702 }
703 }
704 } else {
705 tracing::warn!(tool = %tool_name, "Tool not found");
706 (format!("Error: Unknown tool '{}'", tool_name), false)
707 };
708
709 let _ = event_tx
710 .send(SessionEvent::ToolCallComplete {
711 name: tool_name.clone(),
712 output: content.clone(),
713 success,
714 })
715 .await;
716
717 self.add_message(Message {
718 role: Role::Tool,
719 content: vec![ContentPart::ToolResult {
720 tool_call_id: tool_id,
721 content,
722 }],
723 });
724 }
725 }
726
727 self.save().await?;
728
729 let _ = event_tx
730 .send(SessionEvent::TextComplete(final_output.trim().to_string()))
731 .await;
732 let _ = event_tx.send(SessionEvent::Done).await;
733
734 Ok(SessionResult {
735 text: final_output.trim().to_string(),
736 session_id: self.id.clone(),
737 })
738 }
739
740 pub async fn generate_title(&mut self) -> Result<()> {
743 if self.title.is_some() {
744 return Ok(());
745 }
746
747 let first_message = self
749 .messages
750 .iter()
751 .find(|m| m.role == crate::provider::Role::User);
752
753 if let Some(msg) = first_message {
754 let text: String = msg
755 .content
756 .iter()
757 .filter_map(|p| match p {
758 crate::provider::ContentPart::Text { text } => Some(text.clone()),
759 _ => None,
760 })
761 .collect::<Vec<_>>()
762 .join(" ");
763
764 self.title = Some(truncate_with_ellipsis(&text, 47));
766 }
767
768 Ok(())
769 }
770
771 pub async fn regenerate_title(&mut self) -> Result<()> {
774 let first_message = self
776 .messages
777 .iter()
778 .find(|m| m.role == crate::provider::Role::User);
779
780 if let Some(msg) = first_message {
781 let text: String = msg
782 .content
783 .iter()
784 .filter_map(|p| match p {
785 crate::provider::ContentPart::Text { text } => Some(text.clone()),
786 _ => None,
787 })
788 .collect::<Vec<_>>()
789 .join(" ");
790
791 self.title = Some(truncate_with_ellipsis(&text, 47));
793 }
794
795 Ok(())
796 }
797
798 pub fn set_title(&mut self, title: impl Into<String>) {
800 self.title = Some(title.into());
801 self.updated_at = Utc::now();
802 }
803
804 pub fn clear_title(&mut self) {
806 self.title = None;
807 self.updated_at = Utc::now();
808 }
809
810 pub async fn on_context_change(&mut self, regenerate_title: bool) -> Result<()> {
813 self.updated_at = Utc::now();
814
815 if regenerate_title {
816 self.regenerate_title().await?;
817 }
818
819 Ok(())
820 }
821
822 fn sessions_dir() -> Result<PathBuf> {
824 crate::config::Config::data_dir()
825 .map(|d| d.join("sessions"))
826 .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))
827 }
828
829 fn session_path(id: &str) -> Result<PathBuf> {
831 Ok(Self::sessions_dir()?.join(format!("{}.json", id)))
832 }
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize)]
837pub struct SessionResult {
838 pub text: String,
839 pub session_id: String,
840}
841
842#[derive(Debug, Clone)]
844pub enum SessionEvent {
845 Thinking,
847 ToolCallStart { name: String, arguments: String },
849 ToolCallComplete {
851 name: String,
852 output: String,
853 success: bool,
854 },
855 TextChunk(String),
857 TextComplete(String),
859 Done,
861 Error(String),
863}
864
865pub async fn list_sessions() -> Result<Vec<SessionSummary>> {
867 let sessions_dir = crate::config::Config::data_dir()
868 .map(|d| d.join("sessions"))
869 .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
870
871 if !sessions_dir.exists() {
872 return Ok(Vec::new());
873 }
874
875 let mut summaries = Vec::new();
876 let mut entries = fs::read_dir(&sessions_dir).await?;
877
878 while let Some(entry) = entries.next_entry().await? {
879 let path = entry.path();
880 if path.extension().map(|e| e == "json").unwrap_or(false) {
881 if let Ok(content) = fs::read_to_string(&path).await {
882 if let Ok(session) = serde_json::from_str::<Session>(&content) {
883 summaries.push(SessionSummary {
884 id: session.id,
885 title: session.title,
886 created_at: session.created_at,
887 updated_at: session.updated_at,
888 message_count: session.messages.len(),
889 agent: session.agent,
890 directory: session.metadata.directory,
891 });
892 }
893 }
894 }
895 }
896
897 summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
898 Ok(summaries)
899}
900
901pub async fn list_sessions_for_directory(dir: &std::path::Path) -> Result<Vec<SessionSummary>> {
906 let all = list_sessions().await?;
907 let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
908 Ok(all
909 .into_iter()
910 .filter(|s| {
911 s.directory
912 .as_ref()
913 .map(|d| d.canonicalize().unwrap_or_else(|_| d.clone()) == canonical)
914 .unwrap_or(false)
915 })
916 .collect())
917}
918
919#[derive(Debug, Clone, Serialize, Deserialize)]
921pub struct SessionSummary {
922 pub id: String,
923 pub title: Option<String>,
924 pub created_at: DateTime<Utc>,
925 pub updated_at: DateTime<Utc>,
926 pub message_count: usize,
927 pub agent: String,
928 #[serde(default)]
930 pub directory: Option<PathBuf>,
931}
932
933fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
934 if max_chars == 0 {
935 return String::new();
936 }
937
938 let mut chars = value.chars();
939 let mut output = String::new();
940 for _ in 0..max_chars {
941 if let Some(ch) = chars.next() {
942 output.push(ch);
943 } else {
944 return value.to_string();
945 }
946 }
947
948 if chars.next().is_some() {
949 format!("{output}...")
950 } else {
951 output
952 }
953}
954
955#[allow(dead_code)]
957use futures::StreamExt;
958
959#[allow(dead_code)]
960trait AsyncCollect<T> {
961 async fn collect(self) -> Vec<T>;
962}
963
964#[allow(dead_code)]
965impl<S, T> AsyncCollect<T> for S
966where
967 S: futures::Stream<Item = T> + Unpin,
968{
969 async fn collect(mut self) -> Vec<T> {
970 let mut items = Vec::new();
971 while let Some(item) = self.next().await {
972 items.push(item);
973 }
974 items
975 }
976}