1pub mod citations;
2pub mod locate;
3pub mod save;
4
5use crate::server_client::dto::ChunkResult;
6use kimun_core::nfs::VaultPath;
7use kimun_core::note::BREADCRUMB_SEP;
8
9const HISTORY_WINDOW: usize = 5;
11
12const HEADING_JOINER: &str = " \u{203a} ";
15
16#[derive(Debug, Clone)]
19pub struct AskSource {
20 pub path: VaultPath,
21 pub heading: String,
22 pub date: Option<String>,
26 pub score: f64,
27 pub text: String,
28 pub ordinal: usize,
33}
34
35impl AskSource {
36 pub fn from_chunk(position: usize, c: ChunkResult) -> Self {
54 let stripped = strip_date_prefix(&c.title, c.date.as_deref());
55 let heading = stripped
56 .split(BREADCRUMB_SEP)
57 .filter(|s| !s.is_empty())
58 .collect::<Vec<_>>()
59 .join(HEADING_JOINER);
60 Self {
61 path: VaultPath::new(&c.path),
62 heading,
63 date: c.date,
64 score: c.similarity_score,
65 text: c.content,
66 ordinal: if c.ordinal == 0 {
67 position + 1
68 } else {
69 c.ordinal
70 },
71 }
72 }
73
74 pub fn match_heading(&self) -> &str {
79 self.heading
80 .rsplit(HEADING_JOINER)
81 .next()
82 .unwrap_or(&self.heading)
83 }
84
85 pub fn display_heading(&self) -> String {
89 match &self.date {
90 Some(date) if !self.heading.is_empty() => format!("{date} · {}", self.heading),
91 Some(date) => date.clone(),
92 None => self.heading.clone(),
93 }
94 }
95}
96
97fn strip_date_prefix(title: &str, date: Option<&str>) -> String {
102 let trimmed = title.trim();
103 match date {
104 Some(date) => trimmed
105 .strip_prefix(date)
106 .map(|rest| rest.trim().to_string())
107 .unwrap_or_else(|| trimmed.to_string()),
108 None => trimmed.to_string(),
109 }
110}
111
112#[allow(dead_code)]
115pub enum TurnStatus {
116 Thinking,
117 Streaming,
118 Done,
119 Error(String),
120}
121
122pub struct Turn {
124 pub id: u64,
125 pub question: String,
126 pub answer: String,
127 pub sources: Vec<AskSource>,
128 pub status: TurnStatus,
129}
130
131impl Turn {
132 pub fn source_for_citation(&self, n: usize) -> Option<&AskSource> {
138 self.sources.iter().find(|s| s.ordinal == n)
139 }
140}
141
142#[derive(Default)]
145pub struct Thread {
146 turns: Vec<Turn>,
147 next_id: u64,
148 selected: usize,
149}
150
151impl Thread {
152 pub fn ask(&mut self, question: String) -> u64 {
154 let id = self.bump();
155 self.turns.push(Turn {
156 id,
157 question,
158 answer: String::new(),
159 sources: vec![],
160 status: TurnStatus::Thinking,
161 });
162 self.selected = self.turns.len() - 1;
163 id
164 }
165
166 pub fn complete(&mut self, id: u64, answer: String, sources: Vec<AskSource>) -> bool {
169 let Some(turn) = self.thinking_turn_mut(id) else {
170 return false;
171 };
172 turn.answer = answer;
173 turn.sources = sources;
174 turn.status = TurnStatus::Done;
175 true
176 }
177
178 pub fn fail(&mut self, id: u64, error: String) -> bool {
180 let Some(turn) = self.thinking_turn_mut(id) else {
181 return false;
182 };
183 turn.status = TurnStatus::Error(error);
184 true
185 }
186
187 pub fn regenerate(&mut self, id: u64) -> Option<String> {
190 let turn = self.turns.iter_mut().find(|t| t.id == id)?;
191 if matches!(turn.status, TurnStatus::Thinking | TurnStatus::Streaming) {
192 return None;
193 }
194 turn.status = TurnStatus::Thinking;
195 Some(turn.question.clone())
196 }
197
198 pub fn history(&self) -> Vec<(String, String)> {
202 let boundary = self
203 .turns
204 .iter()
205 .rposition(|t| matches!(t.status, TurnStatus::Thinking | TurnStatus::Streaming))
206 .unwrap_or(self.turns.len());
207 let mut done: Vec<_> = self.turns[..boundary]
213 .iter()
214 .filter(|t| matches!(t.status, TurnStatus::Done))
215 .rev()
216 .take(HISTORY_WINDOW)
217 .collect();
218 done.reverse();
219 done.into_iter()
220 .map(|t| (t.question.clone(), citations::strip(&t.answer)))
221 .collect()
222 }
223
224 pub fn selected(&self) -> Option<&Turn> {
226 self.turns.get(self.selected)
227 }
228
229 pub fn select_prev(&mut self) {
231 self.selected = self.selected.saturating_sub(1);
232 }
233
234 pub fn select_next(&mut self) {
236 if self.selected + 1 < self.turns.len() {
237 self.selected += 1;
238 }
239 }
240
241 pub fn select_last(&mut self) {
243 self.selected = self.turns.len().saturating_sub(1);
244 }
245
246 pub fn select_index(&mut self, idx: usize) {
249 if self.turns.is_empty() {
250 return;
251 }
252 self.selected = idx.min(self.turns.len() - 1);
253 }
254
255 pub fn clear(&mut self) {
257 self.turns.clear();
258 self.selected = 0;
259 }
260
261 pub fn turns(&self) -> &[Turn] {
263 &self.turns
264 }
265
266 pub fn is_empty(&self) -> bool {
268 self.turns.is_empty()
269 }
270
271 fn bump(&mut self) -> u64 {
272 let id = self.next_id;
273 self.next_id += 1;
274 id
275 }
276
277 fn thinking_turn_mut(&mut self, id: u64) -> Option<&mut Turn> {
278 self.turns
279 .iter_mut()
280 .find(|t| t.id == id && matches!(t.status, TurnStatus::Thinking))
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::server_client::dto::ChunkResult;
288
289 fn ask_source(path: &str, ordinal: usize) -> AskSource {
290 AskSource {
291 path: VaultPath::new(path),
292 heading: "h".into(),
293 date: None,
294 score: 1.0,
295 text: String::new(),
296 ordinal,
297 }
298 }
299
300 fn turn_with_sources(sources: Vec<AskSource>) -> Turn {
301 Turn {
302 id: 0,
303 question: "q".into(),
304 answer: String::new(),
305 sources,
306 status: TurnStatus::Done,
307 }
308 }
309
310 #[test]
311 fn source_for_citation_matches_by_ordinal_not_position() {
312 let turn = turn_with_sources(vec![
314 ask_source("c.md", 3),
315 ask_source("a.md", 1),
316 ask_source("b.md", 2),
317 ]);
318 assert_eq!(
320 turn.source_for_citation(1).unwrap().path.to_string(),
321 "a.md"
322 );
323 assert_eq!(
324 turn.source_for_citation(2).unwrap().path.to_string(),
325 "b.md"
326 );
327 assert_eq!(
328 turn.source_for_citation(3).unwrap().path.to_string(),
329 "c.md"
330 );
331 }
332
333 #[test]
334 fn source_for_citation_returns_none_for_a_gap() {
335 let turn = turn_with_sources(vec![ask_source("a.md", 1), ask_source("c.md", 3)]);
337 assert!(turn.source_for_citation(2).is_none());
338 }
339
340 #[test]
341 fn from_chunk_falls_back_to_position_when_ordinal_absent() {
342 let wire = ChunkResult {
343 path: "a.md".into(),
344 title: "t".into(),
345 date: None,
346 content: String::new(),
347 hash: String::new(),
348 similarity_score: 0.9,
349 ordinal: 0, };
351 assert_eq!(AskSource::from_chunk(4, wire).ordinal, 5);
353 }
354
355 #[test]
356 fn from_chunk_honors_a_server_assigned_ordinal() {
357 let wire = ChunkResult {
358 path: "a.md".into(),
359 title: "t".into(),
360 date: None,
361 content: String::new(),
362 hash: String::new(),
363 similarity_score: 0.9,
364 ordinal: 7,
365 };
366 assert_eq!(AskSource::from_chunk(0, wire).ordinal, 7);
368 }
369
370 #[test]
371 fn from_chunk_splits_a_date_prefixed_journal_title() {
372 let wire = ChunkResult {
375 path: "journal/2026-04-08.md".into(),
376 title: "2026-04-08Afternoon".into(),
377 date: Some("2026-04-08".into()),
378 content: String::new(),
379 hash: String::new(),
380 similarity_score: 0.9,
381 ordinal: 1,
382 };
383 let src = AskSource::from_chunk(0, wire);
384 assert_eq!(src.heading, "Afternoon");
385 assert_eq!(src.date.as_deref(), Some("2026-04-08"));
386 assert_eq!(src.display_heading(), "2026-04-08 · Afternoon");
387 }
388
389 #[test]
390 fn from_chunk_renders_a_nested_breadcrumb_title_readably() {
391 let wire = ChunkResult {
395 path: "notes/book.md".into(),
396 title: format!("Chapter{}Section", kimun_core::note::BREADCRUMB_SEP),
397 date: None,
398 content: String::new(),
399 hash: String::new(),
400 similarity_score: 0.5,
401 ordinal: 1,
402 };
403 let src = AskSource::from_chunk(0, wire);
404 assert_eq!(src.heading, "Chapter \u{203a} Section");
405 assert!(!src.heading.contains('\u{1f}'), "no control char leaks");
406 assert_eq!(src.display_heading(), "Chapter \u{203a} Section");
407 assert_eq!(src.match_heading(), "Section");
409 }
410
411 #[test]
412 fn nested_source_locates_via_the_innermost_heading() {
413 use crate::ask::locate;
414 let wire = ChunkResult {
418 path: "notes/book.md".into(),
419 title: format!("Chapter{}Section", kimun_core::note::BREADCRUMB_SEP),
420 date: None,
421 content: "normalized, not verbatim".into(),
422 hash: String::new(),
423 similarity_score: 0.5,
424 ordinal: 1,
425 };
426 let src = AskSource::from_chunk(0, wire);
427 let note = "# Chapter\nintro\n## Section\nthe real body\n";
428 let r = locate::section_range(note, src.match_heading(), &src.text).unwrap();
429 assert!(note[r].contains("the real body"));
430 }
431
432 #[test]
433 fn from_chunk_leaves_a_non_journal_title_unchanged() {
434 let wire = ChunkResult {
435 path: "notes/ideas.md".into(),
436 title: "Project Ideas".into(),
437 date: None,
438 content: String::new(),
439 hash: String::new(),
440 similarity_score: 0.5,
441 ordinal: 1,
442 };
443 let src = AskSource::from_chunk(0, wire);
444 assert_eq!(src.heading, "Project Ideas");
445 assert_eq!(src.date, None);
446 assert_eq!(src.display_heading(), "Project Ideas");
447 }
448
449 fn done(thread: &mut Thread, q: &str, a: &str) {
450 let id = thread.ask(q.to_string());
451 assert!(thread.complete(id, a.to_string(), vec![]));
452 }
453
454 #[test]
455 fn ask_appends_a_thinking_turn_and_selects_it() {
456 let mut t = Thread::default();
457 let id = t.ask("q?".into());
458 assert_eq!(t.turns().len(), 1);
459 assert!(matches!(t.selected().unwrap().status, TurnStatus::Thinking));
460 assert_eq!(t.selected().unwrap().id, id);
461 }
462
463 #[test]
464 fn history_takes_last_five_done_turns_and_strips_citations() {
465 let mut t = Thread::default();
466 for i in 0..7 {
467 done(&mut t, &format!("q{i}"), &format!("a{i} [1]"));
468 }
469 t.ask("new".into()); let h = t.history();
471 assert_eq!(h.len(), 5);
472 assert_eq!(h[0].0, "q2");
473 assert_eq!(h[4].1, "a6"); }
475
476 #[test]
477 fn stale_completion_is_dropped() {
478 let mut t = Thread::default();
479 let id = t.ask("q".into());
480 t.clear();
481 assert!(!t.complete(id, "late".into(), vec![]));
482 assert!(t.is_empty());
483 }
484
485 #[test]
486 fn stale_fail_is_dropped() {
487 let mut t = Thread::default();
488 let id = t.ask("q".into());
489 t.clear();
490 assert!(!t.fail(id, "late error".into()));
491 assert!(t.is_empty());
492 }
493
494 #[test]
495 fn history_skips_error_turns_but_keeps_the_dones_around_them() {
496 let mut t = Thread::default();
497 done(&mut t, "q0", "a0");
498 let err_id = t.ask("q1".into());
499 t.fail(err_id, "boom".into());
500 done(&mut t, "q2", "a2");
501 let h = t.history();
502 assert_eq!(h.len(), 2, "the Error turn itself is not in history");
503 assert_eq!(h[0].0, "q0");
504 assert_eq!(h[1].0, "q2");
505 }
506
507 #[test]
508 fn regenerate_returns_none_for_unknown_id_or_a_thinking_turn() {
509 let mut t = Thread::default();
510 assert!(t.regenerate(999).is_none(), "unknown id");
511 let id = t.ask("q".into()); assert!(
513 t.regenerate(id).is_none(),
514 "in-flight turn can't regenerate"
515 );
516 }
517
518 #[test]
519 fn select_prev_and_select_next_clamp_at_the_ends() {
520 let mut t = Thread::default();
521 done(&mut t, "q0", "a0");
522 done(&mut t, "q1", "a1"); t.select_prev();
525 assert_eq!(t.selected().unwrap().question, "q0");
526 t.select_prev(); assert_eq!(t.selected().unwrap().question, "q0");
528
529 t.select_next();
530 assert_eq!(t.selected().unwrap().question, "q1");
531 t.select_next(); assert_eq!(t.selected().unwrap().question, "q1");
533 }
534
535 #[test]
536 fn select_index_clamps_to_valid_range_and_noops_on_empty() {
537 let mut t = Thread::default();
538 t.select_index(3); assert!(t.selected().is_none());
540
541 done(&mut t, "q0", "a0");
542 done(&mut t, "q1", "a1");
543 done(&mut t, "q2", "a2");
544 t.select_index(1);
545 assert_eq!(t.selected().unwrap().question, "q1");
546 t.select_index(100);
547 assert_eq!(
548 t.selected().unwrap().question,
549 "q2",
550 "clamps to the last turn"
551 );
552 }
553
554 #[test]
555 fn regenerate_rewinds_a_done_turn_keeping_sources() {
556 let mut t = Thread::default();
557 let id = t.ask("q".into());
558 let src = AskSource {
559 path: kimun_core::nfs::VaultPath::new("a.md"),
560 heading: "h".into(),
561 date: None,
562 score: 0.9,
563 text: "body".into(),
564 ordinal: 1,
565 };
566 t.complete(id, "a".into(), vec![src]);
567 assert_eq!(t.regenerate(id).as_deref(), Some("q"));
568 let turn = t.selected().unwrap();
569 assert!(matches!(turn.status, TurnStatus::Thinking));
570 assert_eq!(turn.sources.len(), 1, "regenerate reuses the same sources");
571 }
572}