Skip to main content

ragrig_gpui/rag/
controller.rs

1//! The GPUI-side controller: commands in, Ragrig events out.
2//!
3//! [`RagController`] owns the worker thread, mirrors the state widgets need
4//! (sessions, library, component status), and re-emits every [`RagEvent`].
5//! It never runs Ragrig futures on GPUI's executor.
6
7use std::{
8    path::PathBuf,
9    thread,
10    time::{Duration, SystemTime},
11};
12
13use async_channel::{Sender, TrySendError};
14use gpui_kit::{Context, EventEmitter};
15
16use crate::model::{AgentComponent, ComponentKind, ComponentStatus, ComponentToggle, SessionEntry};
17#[cfg(feature = "grobid")]
18use crate::references::{ReferenceEntry, ReferenceOptions, ReferenceProgress, ReferenceReport};
19
20use super::{
21    AgentInfo, IngestProgress, LibrarySnapshot, PendingAttachment, RagCommand, RagEvent, RagSetup,
22    RequestId, SessionInfo, actor, count_label,
23};
24
25/// Sends commands to the Ragrig worker and mirrors its state for widgets.
26pub struct RagController {
27    commands: Sender<RagCommand>,
28    worker: Option<thread::JoinHandle<()>>,
29    sessions: Vec<SessionEntry>,
30    current_session: Option<String>,
31    library: LibrarySnapshot,
32    agent: Option<AgentInfo>,
33    /// Whether conversation memory is on; mirrors the setup while a rebuild
34    /// is in flight so the agent bar never flips back to a stale value.
35    memory_enabled: bool,
36    /// Whether hybrid retrieval (embeddings) is on.
37    hybrid: bool,
38    ingest: Option<IngestProgress>,
39    sync_request: Option<RequestId>,
40    busy: bool,
41    last_error: Option<String>,
42    last_notice: Option<String>,
43    next_request: u64,
44    #[cfg(feature = "grobid")]
45    references: Vec<ReferenceEntry>,
46    #[cfg(feature = "grobid")]
47    reference_bibliography: Option<PathBuf>,
48    #[cfg(feature = "grobid")]
49    reference_report: Option<ReferenceReport>,
50    #[cfg(feature = "grobid")]
51    reference_progress: Option<ReferenceProgress>,
52    #[cfg(feature = "grobid")]
53    reference_request: Option<RequestId>,
54    #[cfg(feature = "grobid")]
55    reference_folder: Option<PathBuf>,
56}
57
58impl RagController {
59    /// Spawn the worker and start mirroring its events.
60    ///
61    /// Returns an error when the worker thread cannot be created; an agent
62    /// that fails to build is reported later through
63    /// [`RagEvent::AgentFailed`].
64    pub fn new(setup: RagSetup, cx: &mut Context<Self>) -> Result<Self, String> {
65        let memory_enabled = setup.memory_enabled();
66        let hybrid = setup.hybrid;
67        let (commands, command_rx) = async_channel::bounded(32);
68        let (events_tx, events) = async_channel::bounded(256);
69        let worker = actor::start(setup, None, command_rx, events_tx)
70            .map_err(|error| format!("cannot start the RAG worker: {error}"))?;
71
72        cx.spawn(async move |this, cx| {
73            while let Ok(event) = events.recv().await {
74                if this
75                    .update(cx, |controller, cx| controller.on_event(event, cx))
76                    .is_err()
77                {
78                    break;
79                }
80            }
81        })
82        .detach();
83
84        let controller = Self {
85            commands,
86            worker: Some(worker),
87            sessions: Vec::new(),
88            current_session: None,
89            library: LibrarySnapshot::default(),
90            agent: None,
91            memory_enabled,
92            hybrid,
93            ingest: None,
94            sync_request: None,
95            busy: false,
96            last_error: None,
97            last_notice: None,
98            next_request: 0,
99            #[cfg(feature = "grobid")]
100            references: Vec::new(),
101            #[cfg(feature = "grobid")]
102            reference_bibliography: None,
103            #[cfg(feature = "grobid")]
104            reference_report: None,
105            #[cfg(feature = "grobid")]
106            reference_progress: None,
107            #[cfg(feature = "grobid")]
108            reference_request: None,
109            #[cfg(feature = "grobid")]
110            reference_folder: None,
111        };
112        // The worker announces ready, the library, and the session list as
113        // soon as its agent is built.
114        Ok(controller)
115    }
116
117    /// Ask one question and return the request identity.
118    pub fn ask(
119        &mut self,
120        prompt: impl Into<String>,
121        attachments: Vec<PendingAttachment>,
122        cx: &mut Context<Self>,
123    ) -> Result<RequestId, String> {
124        self.ask_with_retrieval(prompt, None, attachments, cx)
125    }
126
127    /// Ask one question whose retrieval is enriched with `retrieval`.
128    ///
129    /// The worker prepends `retrieval` to the prompt before embedding and
130    /// searching, so a marked passage drives the similarity search as well as
131    /// the question. An empty or whitespace-only enrichment is ignored. The
132    /// host transcript still echoes only the prompt.
133    pub fn ask_with_retrieval(
134        &mut self,
135        prompt: impl Into<String>,
136        retrieval: Option<String>,
137        attachments: Vec<PendingAttachment>,
138        cx: &mut Context<Self>,
139    ) -> Result<RequestId, String> {
140        let prompt = prompt.into();
141        let prompt = prompt.trim().to_owned();
142        if prompt.is_empty() {
143            return Err("enter a question first".to_owned());
144        }
145        let retrieval = retrieval
146            .map(|text| text.trim().to_owned())
147            .filter(|text| !text.is_empty());
148        let id = self.request_id();
149        self.send(
150            RagCommand::Ask {
151                id,
152                prompt,
153                retrieval,
154                attachments,
155            },
156            cx,
157        )?;
158        Ok(id)
159    }
160
161    /// Stop one running generation.
162    pub fn stop(&mut self, id: RequestId, cx: &mut Context<Self>) {
163        self.send_quiet(RagCommand::Stop { id }, cx);
164    }
165
166    /// Start an empty conversation.
167    pub fn new_session(&mut self, cx: &mut Context<Self>) {
168        self.send_quiet(RagCommand::NewSession, cx);
169    }
170
171    /// Continue a saved conversation.
172    pub fn load_session(&mut self, id: impl Into<String>, cx: &mut Context<Self>) {
173        self.send_quiet(RagCommand::LoadSession { id: id.into() }, cx);
174    }
175
176    /// Re-read the session store.
177    pub fn refresh_sessions(&mut self, cx: &mut Context<Self>) {
178        self.send_quiet(RagCommand::RefreshSessions, cx);
179    }
180
181    /// Re-read the vector store.
182    pub fn refresh_library(&mut self, cx: &mut Context<Self>) {
183        self.send_quiet(RagCommand::RefreshLibrary, cx);
184    }
185
186    /// Index a folder as a named corpus.
187    pub fn sync_corpus(
188        &mut self,
189        name: impl Into<String>,
190        folder: impl Into<PathBuf>,
191        cx: &mut Context<Self>,
192    ) -> Result<RequestId, String> {
193        let id = self.request_id();
194        self.send(
195            RagCommand::SyncCorpus {
196                id,
197                name: name.into(),
198                folder: folder.into(),
199            },
200            cx,
201        )?;
202        Ok(id)
203    }
204
205    /// Stop the running ingestion.
206    pub fn stop_sync(&mut self, cx: &mut Context<Self>) {
207        if let Some(id) = self.sync_request {
208            self.send_quiet(RagCommand::StopSync { id }, cx);
209        }
210    }
211
212    /// Parse `folder` with GROBID and merge fresh records into the workspace
213    /// bibliography.
214    #[cfg(feature = "grobid")]
215    pub fn extract_references(
216        &mut self,
217        folder: impl Into<PathBuf>,
218        options: ReferenceOptions,
219        cx: &mut Context<Self>,
220    ) -> Result<RequestId, String> {
221        let folder = folder.into();
222        let id = self.request_id();
223        self.reference_folder = Some(folder.clone());
224        self.send(
225            RagCommand::ExtractReferences {
226                id,
227                folder,
228                options,
229            },
230            cx,
231        )?;
232        Ok(id)
233    }
234
235    /// Stop the running reference extraction.
236    #[cfg(feature = "grobid")]
237    pub fn stop_references(&mut self, cx: &mut Context<Self>) {
238        if let Some(id) = self.reference_request {
239            self.send_quiet(RagCommand::StopReferences { id }, cx);
240        }
241    }
242
243    /// Load the workspace bibliography without contacting GROBID.
244    #[cfg(feature = "grobid")]
245    pub fn refresh_references(&mut self, folder: impl Into<PathBuf>, cx: &mut Context<Self>) {
246        let folder = folder.into();
247        self.reference_folder = Some(folder.clone());
248        self.send_quiet(RagCommand::RefreshReferences { folder }, cx);
249    }
250
251    /// Rebuild the agent with new configuration.
252    pub fn apply(&mut self, setup: RagSetup, cx: &mut Context<Self>) {
253        self.memory_enabled = setup.memory_enabled();
254        self.hybrid = setup.hybrid;
255        self.agent = None;
256        self.busy = false;
257        self.ingest = None;
258        self.sync_request = None;
259        self.current_session = None;
260        self.library = LibrarySnapshot::default();
261        self.last_error = None;
262        self.last_notice = None;
263        #[cfg(feature = "grobid")]
264        let reference_folder = self.reference_folder.clone();
265        #[cfg(feature = "grobid")]
266        {
267            self.references.clear();
268            self.reference_bibliography = None;
269            self.reference_report = None;
270            self.reference_progress = None;
271            self.reference_request = None;
272        }
273        self.send_quiet(
274            RagCommand::Apply {
275                setup: Box::new(setup),
276            },
277            cx,
278        );
279        #[cfg(feature = "grobid")]
280        if let Some(folder) = reference_folder {
281            self.send_quiet(RagCommand::RefreshReferences { folder }, cx);
282        }
283    }
284
285    /// Saved conversations, newest first.
286    pub fn sessions(&self) -> &[SessionEntry] {
287        &self.sessions
288    }
289
290    /// The active conversation id, when known.
291    pub fn current_session(&self) -> Option<&str> {
292        self.current_session.as_deref()
293    }
294
295    /// The last library snapshot.
296    pub fn library(&self) -> &LibrarySnapshot {
297        &self.library
298    }
299
300    /// The active agent, once it built successfully.
301    pub fn agent(&self) -> Option<&AgentInfo> {
302        self.agent.as_ref()
303    }
304
305    /// The running ingestion, if any.
306    pub fn ingest_progress(&self) -> Option<&IngestProgress> {
307        self.ingest.as_ref()
308    }
309
310    /// Whether an answer is streaming.
311    pub fn is_busy(&self) -> bool {
312        self.busy
313    }
314
315    /// Whether a corpus is being indexed.
316    pub fn is_ingesting(&self) -> bool {
317        self.ingest.is_some()
318    }
319
320    /// Reference rows loaded from the workspace bibliography.
321    #[cfg(feature = "grobid")]
322    pub fn references(&self) -> &[ReferenceEntry] {
323        &self.references
324    }
325
326    /// The `.bib` file backing the reference rows.
327    #[cfg(feature = "grobid")]
328    pub fn reference_bibliography(&self) -> Option<&std::path::Path> {
329        self.reference_bibliography.as_deref()
330    }
331
332    /// What the last reference extraction did.
333    #[cfg(feature = "grobid")]
334    pub fn reference_report(&self) -> Option<&ReferenceReport> {
335        self.reference_report.as_ref()
336    }
337
338    /// Progress of the running reference extraction.
339    #[cfg(feature = "grobid")]
340    pub fn reference_progress(&self) -> Option<&ReferenceProgress> {
341        self.reference_progress.as_ref()
342    }
343
344    /// Whether GROBID is currently parsing a corpus.
345    #[cfg(feature = "grobid")]
346    pub fn is_extracting_references(&self) -> bool {
347        self.reference_progress.is_some()
348    }
349
350    /// The last fatal error.
351    pub fn error(&self) -> Option<&str> {
352        self.last_error.as_deref()
353    }
354
355    /// One line for a status bar.
356    pub fn status_line(&self) -> String {
357        if let Some(error) = &self.last_error {
358            return error.clone();
359        }
360        if let Some(progress) = &self.ingest {
361            return progress.summary();
362        }
363        #[cfg(feature = "grobid")]
364        if let Some(progress) = &self.reference_progress {
365            return progress.summary();
366        }
367        if self.busy {
368            return "Generating…".to_owned();
369        }
370        match &self.agent {
371            Some(agent) => format!(
372                "{} · {} · {}",
373                agent.chat_model,
374                count_label(agent.documents, "document", "documents"),
375                count_label(agent.chunks, "chunk", "chunks")
376            ),
377            None => self
378                .last_notice
379                .clone()
380                .unwrap_or_else(|| "Starting the RAG worker…".to_owned()),
381        }
382    }
383
384    /// Whether conversation memory is on.
385    pub fn memory_enabled(&self) -> bool {
386        self.memory_enabled
387    }
388
389    /// Whether hybrid retrieval (embeddings) is on.
390    pub fn hybrid(&self) -> bool {
391        self.hybrid
392    }
393
394    /// Pipeline components for [`AgentBar`](crate::AgentBar).
395    pub fn agent_components(&self) -> Vec<AgentComponent> {
396        let memory_toggle = ComponentToggle::new("memory", "Enabled", self.memory_enabled)
397            .detail("Conversation transcript and query rewriting.");
398        let hybrid_toggle = ComponentToggle::new("hybrid", "Enabled", self.hybrid)
399            .detail("BM25 plus embeddings. Off needs no embedding model.");
400
401        let Some(agent) = &self.agent else {
402            let status = if self.last_error.is_some() {
403                ComponentStatus::Error
404            } else {
405                ComponentStatus::Inactive
406            };
407            let memory_status = if self.memory_enabled {
408                status
409            } else {
410                ComponentStatus::Inactive
411            };
412            let retrieval_status = if self.hybrid {
413                status
414            } else {
415                ComponentStatus::Inactive
416            };
417            let components = vec![
418                AgentComponent::new("chat", ComponentKind::Chat, "Chat model").status(status),
419                // Memory belongs beside the chat model: it shapes the prompt.
420                AgentComponent::new("memory", ComponentKind::Memory, "Chat memory")
421                    .status(memory_status)
422                    .detail(if self.memory_enabled {
423                        "conversation memory"
424                    } else {
425                        "off · no transcript or rewriting"
426                    })
427                    .toggle(memory_toggle),
428                AgentComponent::new("embed", ComponentKind::Embedder, "Embedder")
429                    .status(retrieval_status)
430                    .detail(if self.hybrid {
431                        "hybrid retrieval"
432                    } else {
433                        "off · no embedding model"
434                    }),
435                AgentComponent::new("store", ComponentKind::Store, "Vector store").status(status),
436                AgentComponent::new("parser", ComponentKind::Parser, "PDF parser").status(status),
437                AgentComponent::new("chunker", ComponentKind::Chunker, "Chunker").status(status),
438                AgentComponent::new("retriever", ComponentKind::Retriever, "Hybrid retrieval")
439                    .status(retrieval_status)
440                    .detail(if self.hybrid {
441                        "BM25 + embeddings"
442                    } else {
443                        "off · no embeddings"
444                    })
445                    .toggle(hybrid_toggle),
446            ];
447            #[cfg(feature = "grobid")]
448            let components = {
449                let mut components = components;
450                components.push(
451                    AgentComponent::new("references", ComponentKind::References, "GROBID")
452                        .status(status),
453                );
454                components
455            };
456            return components;
457        };
458
459        let pipeline = if self.ingest.is_some() {
460            ComponentStatus::Busy
461        } else {
462            ComponentStatus::Ready
463        };
464        let retrieval_status = if self.hybrid {
465            pipeline
466        } else {
467            ComponentStatus::Inactive
468        };
469        let memory_status = if self.memory_enabled {
470            ComponentStatus::Ready
471        } else {
472            ComponentStatus::Inactive
473        };
474        let chat = if self.busy {
475            ComponentStatus::Busy
476        } else {
477            ComponentStatus::Ready
478        };
479
480        let components = vec![
481            AgentComponent::new("chat", ComponentKind::Chat, agent.chat_model.clone())
482                .status(chat)
483                .detail(format!("{} · {} memory", agent.chat_backend, agent.memory)),
484            // Memory belongs beside the chat model: it shapes the prompt.
485            AgentComponent::new("memory", ComponentKind::Memory, "Chat memory")
486                .status(memory_status)
487                .detail(if self.memory_enabled {
488                    format!("{} strategy", agent.memory)
489                } else {
490                    "off · no transcript or rewriting".to_owned()
491                })
492                .toggle(memory_toggle),
493            AgentComponent::new(
494                "embed",
495                ComponentKind::Embedder,
496                if self.hybrid {
497                    agent.embed_model.clone()
498                } else {
499                    "No embeddings".to_owned()
500                },
501            )
502            .status(retrieval_status)
503            .detail(if self.hybrid {
504                format!("{} · top {}", agent.embed_backend, agent.top_k)
505            } else {
506                "off · no embedding model".to_owned()
507            }),
508            AgentComponent::new("store", ComponentKind::Store, "Vector store")
509                .status(pipeline)
510                .detail(format!(
511                    "{} · {}",
512                    count_label(agent.documents, "document", "documents"),
513                    count_label(agent.chunks, "chunk", "chunks")
514                )),
515            AgentComponent::new("parser", ComponentKind::Parser, agent.pdf_backend.clone())
516                .status(pipeline)
517                .detail("PDF and Markdown"),
518            AgentComponent::new("chunker", ComponentKind::Chunker, "Markdown")
519                .status(pipeline)
520                .detail(format!(
521                    "{} tokens · {} overlap",
522                    agent.chunk_size, agent.chunk_overlap
523                )),
524            AgentComponent::new("retriever", ComponentKind::Retriever, "Hybrid retrieval")
525                .status(retrieval_status)
526                .detail(if self.hybrid {
527                    format!("BM25 + embeddings · top {}", agent.top_k)
528                } else {
529                    "off · no embeddings".to_owned()
530                })
531                .toggle(hybrid_toggle),
532        ];
533        #[cfg(feature = "grobid")]
534        let components = {
535            let mut components = components;
536            let status = if self.reference_progress.is_some() {
537                ComponentStatus::Busy
538            } else if self.references.is_empty() {
539                ComponentStatus::Inactive
540            } else {
541                ComponentStatus::Ready
542            };
543            let detail = match &self.reference_bibliography {
544                Some(path) => format!(
545                    "{} · {}",
546                    count_label(self.references.len(), "reference", "references"),
547                    path.file_name()
548                        .and_then(|name| name.to_str())
549                        .unwrap_or("references.bib")
550                ),
551                None => count_label(self.references.len(), "reference", "references"),
552            };
553            components.push(
554                AgentComponent::new("references", ComponentKind::References, "GROBID")
555                    .status(status)
556                    .detail(detail),
557            );
558            components
559        };
560        components
561    }
562
563    fn request_id(&mut self) -> RequestId {
564        self.next_request = self.next_request.wrapping_add(1);
565        RequestId::new(self.next_request)
566    }
567
568    fn send(&self, command: RagCommand, cx: &mut Context<Self>) -> Result<(), String> {
569        self.commands
570            .try_send(command)
571            .map_err(|error| match error {
572                TrySendError::Full(_) => "the RAG worker is busy; try again",
573                TrySendError::Closed(_) => "the RAG worker stopped",
574            })?;
575        cx.notify();
576        Ok(())
577    }
578
579    fn send_quiet(&mut self, command: RagCommand, cx: &mut Context<Self>) {
580        if let Err(message) = self.send(command, cx) {
581            self.last_error = Some(message);
582            cx.notify();
583        }
584    }
585
586    fn on_event(&mut self, event: RagEvent, cx: &mut Context<Self>) {
587        match &event {
588            RagEvent::AgentReady(info) => {
589                self.memory_enabled = info.memory != "off";
590                self.hybrid = info.hybrid;
591                self.agent = Some(info.clone());
592                self.last_error = None;
593            }
594            RagEvent::AgentFailed { message } => {
595                self.agent = None;
596                self.busy = false;
597                self.last_error = Some(message.clone());
598            }
599            RagEvent::SessionStarted { id } | RagEvent::SessionLoaded { id, .. } => {
600                self.current_session = Some(id.clone());
601            }
602            RagEvent::Sessions(sessions) => {
603                self.sessions = session_entries(sessions, SystemTime::now());
604            }
605            RagEvent::Started { .. } => {
606                self.busy = true;
607                self.last_notice = None;
608                self.last_error = None;
609            }
610            RagEvent::Chunk { .. } => {}
611            RagEvent::Finished { .. } | RagEvent::Stopped { .. } => {
612                self.busy = false;
613            }
614            RagEvent::Failed { message, .. } => {
615                self.busy = false;
616                self.last_error = Some(message.clone());
617            }
618            RagEvent::Library(snapshot) => {
619                self.library = snapshot.clone();
620                if let Some(agent) = &mut self.agent {
621                    agent.chunks = snapshot.chunks;
622                    agent.documents = snapshot.documents.len();
623                }
624            }
625            #[cfg(feature = "grobid")]
626            RagEvent::Citations { .. } => {}
627            #[cfg(feature = "grobid")]
628            RagEvent::ReferencesStarted { id, folder } => {
629                self.reference_request = Some(*id);
630                self.reference_folder = Some(folder.clone());
631                self.reference_progress = Some(ReferenceProgress::default());
632                self.last_error = None;
633            }
634            #[cfg(feature = "grobid")]
635            RagEvent::ReferencesProgress { progress, .. } => {
636                self.reference_progress = Some(progress.clone());
637            }
638            #[cfg(feature = "grobid")]
639            RagEvent::ReferencesLoaded {
640                entries,
641                bibliography,
642            } => {
643                self.references = entries.clone();
644                self.reference_bibliography = Some(bibliography.clone());
645            }
646            #[cfg(feature = "grobid")]
647            RagEvent::ReferencesFinished { report, .. } => {
648                self.reference_report = Some(report.clone());
649                self.reference_progress = None;
650                self.reference_request = None;
651            }
652            #[cfg(feature = "grobid")]
653            RagEvent::ReferencesStopped { .. } => {
654                self.reference_progress = None;
655                self.reference_request = None;
656            }
657            #[cfg(feature = "grobid")]
658            RagEvent::ReferencesFailed { message, .. } => {
659                self.reference_progress = None;
660                self.reference_request = None;
661                self.last_error = Some(message.clone());
662            }
663            RagEvent::SyncStarted { id, corpus } => {
664                self.sync_request = Some(*id);
665                self.last_error = None;
666                self.ingest = Some(IngestProgress {
667                    corpus: corpus.clone(),
668                    ..IngestProgress::default()
669                });
670            }
671            RagEvent::SyncProgress { progress, .. } => {
672                self.ingest = Some(progress.clone());
673            }
674            RagEvent::SyncFinished { .. } | RagEvent::SyncStopped { .. } => {
675                self.ingest = None;
676                self.sync_request = None;
677            }
678            RagEvent::SyncFailed { message, .. } => {
679                self.ingest = None;
680                self.sync_request = None;
681                self.last_error = Some(message.clone());
682            }
683            RagEvent::Notice(message) => {
684                self.last_notice = Some(message.clone());
685            }
686        }
687
688        cx.notify();
689        cx.emit(event);
690    }
691}
692
693impl EventEmitter<RagEvent> for RagController {}
694
695impl Drop for RagController {
696    fn drop(&mut self) {
697        self.commands.close();
698        if let Some(worker) = self.worker.take() {
699            let _ = worker.join();
700        }
701    }
702}
703
704fn session_entries(sessions: &[SessionInfo], now: SystemTime) -> Vec<SessionEntry> {
705    let mut sessions = sessions.to_vec();
706    sessions.sort_by_key(|session| std::cmp::Reverse(session.updated));
707    sessions
708        .into_iter()
709        .map(|session| {
710            let label = session
711                .summary
712                .as_deref()
713                .map(str::trim)
714                .filter(|summary| !summary.is_empty())
715                .map(str::to_owned)
716                .unwrap_or_else(|| match session.turn_count {
717                    0 => "Empty conversation".to_owned(),
718                    1 => "1 turn".to_owned(),
719                    count => format!("{count} turns"),
720                });
721            SessionEntry::new(session.id, label).detail(relative_time(now, session.updated))
722        })
723        .collect()
724}
725
726fn relative_time(now: SystemTime, then: SystemTime) -> String {
727    let elapsed = now.duration_since(then).unwrap_or_default();
728    if elapsed < Duration::from_secs(60) {
729        "just now".to_owned()
730    } else if elapsed < Duration::from_secs(3_600) {
731        format!("{} min ago", elapsed.as_secs() / 60)
732    } else if elapsed < Duration::from_secs(86_400) {
733        format!("{} h ago", elapsed.as_secs() / 3_600)
734    } else {
735        format!("{} d ago", elapsed.as_secs() / 86_400)
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    fn info(id: &str, turns: usize, summary: Option<&str>, updated: u64) -> SessionInfo {
744        SessionInfo {
745            id: id.to_owned(),
746            turn_count: turns,
747            summary: summary.map(str::to_owned),
748            updated: SystemTime::UNIX_EPOCH + Duration::from_secs(updated),
749        }
750    }
751
752    #[test]
753    fn sessions_are_sorted_and_labelled() {
754        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
755        let entries = session_entries(
756            &[
757                info("old", 1, None, 1_000),
758                info("new", 3, None, 9_000),
759                info("summary", 0, Some("Methods review"), 5_000),
760            ],
761            now,
762        );
763        assert_eq!(entries[0].id, "new");
764        assert_eq!(entries[0].label, "3 turns");
765        assert_eq!(entries[1].id, "summary");
766        assert_eq!(entries[1].label, "Methods review");
767        assert_eq!(entries[2].id, "old");
768        assert_eq!(entries[2].label, "1 turn");
769    }
770
771    #[test]
772    fn relative_times_are_human_readable() {
773        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000);
774        assert_eq!(relative_time(now, now - Duration::from_secs(5)), "just now");
775        assert_eq!(
776            relative_time(now, now - Duration::from_secs(120)),
777            "2 min ago"
778        );
779        assert_eq!(
780            relative_time(now, now - Duration::from_secs(7_200)),
781            "2 h ago"
782        );
783        assert_eq!(
784            relative_time(now, now - Duration::from_secs(172_800)),
785            "2 d ago"
786        );
787    }
788}