1use serde::Serialize;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
6pub enum PackMode {
7 Compact,
8 Full,
9}
10
11#[derive(Debug, Clone, Serialize)]
12pub struct TaskPack {
13 pub task_id: String,
14 pub mode: PackMode,
15 pub schema_version: String,
16 pub text: String,
17 pub metadata: PackMetadata,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct PackMetadata {
22 pub generated_at: String,
23 pub source_event_count: usize,
24 pub cache_hit: bool,
25 pub truncated: bool,
26}
27
28use anyhow::Context;
29use rusqlite::Connection;
30
31fn render_recent_events(conn: &Connection, task_id: &str, limit: usize) -> anyhow::Result<String> {
32 let mut out = format!("## Recent events (last {limit})\n");
33 let mut stmt = conn.prepare(
34 "SELECT ei.timestamp, ei.type, ei.status, sf.text FROM events_index ei
35 LEFT JOIN search_fts sf ON sf.event_id = ei.event_id
36 WHERE ei.task_id=?1 ORDER BY ei.timestamp DESC LIMIT ?2",
37 )?;
38 let rows = stmt.query_map(rusqlite::params![task_id, limit as i64], |r| {
39 let ts: String = r.get(0)?;
40 let ty: String = r.get(1)?;
41 let st: String = r.get(2)?;
42 let txt: Option<String> = r.get(3)?;
43 Ok((ts, ty, st, txt.unwrap_or_default()))
44 })?;
45 for row in rows {
46 let (ts, ty, st, txt) = row?;
47 let one_line = txt
48 .lines()
49 .next()
50 .unwrap_or("")
51 .chars()
52 .take(120)
53 .collect::<String>();
54 let marker = if st == "suggested" { " [?]" } else { "" };
55 out.push_str(&format!("- {ts} [{ty}]{marker} {one_line}\n"));
56 }
57 out.push('\n');
58 Ok(out)
59}
60
61fn render_evidence(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
62 let mut out = String::from("## Evidence\n");
63 let mut stmt = conn
64 .prepare("SELECT text, strength FROM evidence WHERE task_id=?1 ORDER BY evidence_id ASC")?;
65 let rows = stmt.query_map(rusqlite::params![task_id], |r| {
66 let t: String = r.get(0)?;
67 let s: String = r.get(1)?;
68 Ok((t, s))
69 })?;
70 let mut count = 0;
71 for row in rows {
72 let (t, s) = row?;
73 out.push_str(&format!("- {t} ({s})\n"));
74 count += 1;
75 }
76 if count == 0 {
77 out.push_str("- (none)\n");
78 }
79 out.push('\n');
80 Ok(out)
81}
82
83fn render_rejected(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
84 let mut out = String::from("## Rejected\n");
85 let mut id_stmt = conn.prepare(
86 "SELECT event_id FROM events_index
87 WHERE task_id=?1 AND type='rejection'
88 ORDER BY timestamp ASC",
89 )?;
90 let mut text_stmt = conn.prepare("SELECT text FROM search_fts WHERE event_id=?1 LIMIT 1")?;
91 let event_ids: Vec<String> = id_stmt
92 .query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?
93 .collect::<Result<_, _>>()?;
94 let mut count = 0;
95 for eid in event_ids {
96 let text: String = text_stmt.query_row(rusqlite::params![eid], |r| r.get(0))?;
97 out.push_str(&format!("- {text}\n"));
98 count += 1;
99 }
100 if count == 0 {
101 out.push_str("- (none)\n");
102 }
103 out.push('\n');
104 Ok(out)
105}
106
107fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
108 let mut out = String::from("## Active decisions\n");
109 let mut stmt = conn.prepare(
110 "SELECT text FROM decisions WHERE task_id=?1 AND status='active' ORDER BY decision_id ASC",
111 )?;
112 let rows = stmt.query_map(rusqlite::params![task_id], |r| r.get::<_, String>(0))?;
113 let mut count = 0;
114 for row in rows {
115 out.push_str(&format!("- {}\n", row?));
116 count += 1;
117 }
118 if count == 0 {
119 out.push_str("- (none)\n");
120 }
121 out.push('\n');
122 Ok(out)
123}
124
125fn render_lifecycle(conn: &Connection, task_id: &str) -> anyhow::Result<String> {
126 let mut out = String::from("## Lifecycle\n");
127 let mut stmt = conn.prepare(
128 "SELECT timestamp, type FROM events_index
129 WHERE task_id=?1 AND type IN ('open','close','reopen','supersede','redirect')
130 ORDER BY timestamp ASC",
131 )?;
132 let rows = stmt.query_map(rusqlite::params![task_id], |r| {
133 let ts: String = r.get(0)?;
134 let ty: String = r.get(1)?;
135 Ok((ts, ty))
136 })?;
137 let mut count = 0;
138 for row in rows {
139 let (ts, ty) = row?;
140 let verb = match ty.as_str() {
141 "open" => "opened",
142 "close" => "closed",
143 "reopen" => "reopened",
144 "supersede" => "superseded",
145 "redirect" => "redirected",
146 _ => &ty,
147 };
148 out.push_str(&format!("- {ts} {verb}\n"));
149 count += 1;
150 }
151 if count == 0 {
152 out.push_str("- (none)\n");
153 }
154 out.push('\n');
155 Ok(out)
156}
157
158pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Result<TaskPack> {
159 let mode_str = match mode {
160 PackMode::Compact => "compact",
161 PackMode::Full => "full",
162 };
163
164 let cached: Option<(String, String, i64)> = conn
166 .query_row(
167 "SELECT text, generated_at, source_event_count FROM task_pack_cache
168 WHERE task_id=?1 AND mode=?2",
169 rusqlite::params![task_id, mode_str],
170 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
171 )
172 .ok();
173 if let Some((cached_text, cached_at, cached_count)) = cached {
174 let was_truncated = cached_text.contains("_(truncated to fit pack budget)_");
176 return Ok(TaskPack {
177 task_id: task_id.to_string(),
178 mode,
179 schema_version: crate::SCHEMA_VERSION.into(),
180 text: cached_text,
181 metadata: PackMetadata {
182 generated_at: cached_at,
183 source_event_count: cached_count as usize,
184 cache_hit: true,
185 truncated: was_truncated,
186 },
187 });
188 }
189
190 let (title, status): (String, String) = conn
191 .query_row(
192 "SELECT title, status FROM tasks WHERE task_id=?1",
193 rusqlite::params![task_id],
194 |r| Ok((r.get(0)?, r.get(1)?)),
195 )
196 .with_context(|| format!("task not found: {task_id}"))?;
197
198 let event_count: usize = conn.query_row(
199 "SELECT COUNT(*) FROM events_index WHERE task_id=?1",
200 rusqlite::params![task_id],
201 |r| r.get::<_, i64>(0).map(|n| n as usize),
202 )?;
203
204 let mut text = format!("# {title} [status: {status}]\n\n");
205 if matches!(mode, PackMode::Full) {
206 text.push_str(&render_lifecycle(conn, task_id)?);
207 }
208 text.push_str(&render_active_decisions(conn, task_id)?);
209 if matches!(mode, PackMode::Full) {
210 text.push_str(&render_rejected(conn, task_id)?);
211 text.push_str(&render_evidence(conn, task_id)?);
212 }
213 let recent_limit = match mode {
214 PackMode::Compact => 3,
215 PackMode::Full => 10,
216 };
217 text.push_str(&render_recent_events(conn, task_id, recent_limit)?);
218
219 const FULL_BUDGET: usize = 10 * 1024;
221 const COMPACT_BUDGET: usize = 2 * 1024;
222 const TRUNC_MARKER: &str = "\n\n_(truncated to fit pack budget)_\n";
223 let budget = match mode {
224 PackMode::Full => FULL_BUDGET,
225 PackMode::Compact => COMPACT_BUDGET,
226 };
227 let truncated = text.len() > budget;
228 if truncated {
229 let cutoff = text[..budget].rfind('\n').unwrap_or(budget);
230 text.truncate(cutoff);
231 text.push_str(TRUNC_MARKER);
232 }
233
234 let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
235
236 conn.execute(
238 "INSERT OR REPLACE INTO task_pack_cache(task_id, mode, text, generated_at, source_event_count)
239 VALUES (?1, ?2, ?3, ?4, ?5)",
240 rusqlite::params![task_id, mode_str, text, generated_at, event_count as i64],
241 )?;
242
243 Ok(TaskPack {
244 task_id: task_id.to_string(),
245 mode,
246 schema_version: crate::SCHEMA_VERSION.into(),
247 text,
248 metadata: PackMetadata {
249 generated_at,
250 source_event_count: event_count,
251 cache_hit: false,
252 truncated,
253 },
254 })
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn pack_mode_round_trips_via_serde() {
263 let s = serde_json::to_string(&PackMode::Compact).unwrap();
264 assert_eq!(s, "\"Compact\"");
265 }
266
267 #[test]
268 fn cache_is_invalidated_on_new_event() {
269 use crate::db;
270 use crate::event::*;
271 use tempfile::TempDir;
272
273 let d = TempDir::new().unwrap();
274 let conn = db::open(d.path().join("s.sqlite")).unwrap();
275 let mut open_e = Event::new(
276 "tj-inv",
277 EventType::Open,
278 Author::User,
279 Source::Cli,
280 "x".into(),
281 );
282 open_e.meta = serde_json::json!({"title": "Inv"});
283 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
284 db::index_event(&conn, &open_e).unwrap();
285
286 let _ = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
287 let p2 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
288 assert!(p2.metadata.cache_hit);
289
290 let dec = Event::new(
291 "tj-inv",
292 EventType::Decision,
293 Author::Agent,
294 Source::Chat,
295 "D".into(),
296 );
297 db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
298 db::index_event(&conn, &dec).unwrap();
299
300 let p3 = assemble(&conn, "tj-inv", PackMode::Compact).unwrap();
301 assert!(
302 !p3.metadata.cache_hit,
303 "new event must invalidate the cache"
304 );
305 }
306
307 #[test]
308 fn pack_cache_hits_after_incremental_ingest_with_no_new_events() {
309 use crate::db;
315 use crate::event::*;
316 use std::io::Write;
317 use tempfile::TempDir;
318
319 let d = TempDir::new().unwrap();
320 let jsonl = d.path().join("events.jsonl");
321 let project = "cafef00dcafef00d";
322
323 let mut open_e = Event::new(
324 "tj-cmcp",
325 EventType::Open,
326 Author::User,
327 Source::Cli,
328 "x".into(),
329 );
330 open_e.meta = serde_json::json!({"title": "Cached"});
331 let dec = Event::new(
332 "tj-cmcp",
333 EventType::Decision,
334 Author::Agent,
335 Source::Chat,
336 "Adopt Rust".into(),
337 );
338
339 let mut f = std::fs::File::create(&jsonl).unwrap();
340 writeln!(f, "{}", serde_json::to_string(&open_e).unwrap()).unwrap();
341 writeln!(f, "{}", serde_json::to_string(&dec).unwrap()).unwrap();
342 drop(f);
343
344 let conn = db::open(d.path().join("s.sqlite")).unwrap();
345
346 db::ingest_new_events(&conn, &jsonl, project).unwrap();
348 let first = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
349 assert!(
350 !first.metadata.cache_hit,
351 "first assemble must populate cache"
352 );
353
354 let n_new = db::ingest_new_events(&conn, &jsonl, project).unwrap();
356 assert_eq!(n_new, 0, "no new events should be ingested");
357 let second = assemble(&conn, "tj-cmcp", PackMode::Compact).unwrap();
358 assert!(
359 second.metadata.cache_hit,
360 "repeat assemble after a no-op ingest must hit the cache"
361 );
362 assert_eq!(first.text, second.text);
363 }
364
365 #[test]
366 fn pack_cache_returns_cached_text_on_second_call() {
367 use crate::db;
368 use crate::event::*;
369 use tempfile::TempDir;
370
371 let d = TempDir::new().unwrap();
372 let conn = db::open(d.path().join("s.sqlite")).unwrap();
373 let mut open_e = Event::new(
374 "tj-c",
375 EventType::Open,
376 Author::User,
377 Source::Cli,
378 "x".into(),
379 );
380 open_e.meta = serde_json::json!({"title": "Cache"});
381 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
382 db::index_event(&conn, &open_e).unwrap();
383
384 let p1 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
385 assert!(!p1.metadata.cache_hit);
386 let p2 = assemble(&conn, "tj-c", PackMode::Compact).unwrap();
387 assert!(p2.metadata.cache_hit, "second call should hit cache");
388 assert_eq!(p1.text, p2.text);
389 }
390
391 #[test]
392 fn compact_mode_omits_optional_sections() {
393 use crate::db;
394 use crate::event::*;
395 use tempfile::TempDir;
396
397 let d = TempDir::new().unwrap();
398 let conn = db::open(d.path().join("s.sqlite")).unwrap();
399 let mut open_e = Event::new(
400 "tj-cm",
401 EventType::Open,
402 Author::User,
403 Source::Cli,
404 "x".into(),
405 );
406 open_e.meta = serde_json::json!({"title": "Compact"});
407 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
408 db::index_event(&conn, &open_e).unwrap();
409 let dec = Event::new(
410 "tj-cm",
411 EventType::Decision,
412 Author::Agent,
413 Source::Chat,
414 "D1".into(),
415 );
416 db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
417 db::index_event(&conn, &dec).unwrap();
418
419 let pack = assemble(&conn, "tj-cm", PackMode::Compact).unwrap();
420 assert!(pack.text.contains("# Compact"));
421 assert!(pack.text.contains("Active decisions"));
422 assert!(pack.text.contains("Recent events"));
423 assert!(
424 !pack.text.contains("Lifecycle"),
425 "compact should omit Lifecycle: {}",
426 pack.text
427 );
428 assert!(
429 !pack.text.contains("Rejected"),
430 "compact should omit Rejected: {}",
431 pack.text
432 );
433 assert!(
434 !pack.text.contains("Evidence"),
435 "compact should omit Evidence: {}",
436 pack.text
437 );
438 }
439
440 #[test]
441 fn full_mode_truncates_when_exceeding_budget() {
442 use crate::db;
443 use crate::event::*;
444 use tempfile::TempDir;
445
446 let d = TempDir::new().unwrap();
447 let conn = db::open(d.path().join("s.sqlite")).unwrap();
448 let mut open_e = Event::new(
449 "tj-big",
450 EventType::Open,
451 Author::User,
452 Source::Cli,
453 "x".into(),
454 );
455 open_e.meta = serde_json::json!({"title": "Big"});
456 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
457 db::index_event(&conn, &open_e).unwrap();
458 for i in 0..100 {
459 let ev = Event::new(
460 "tj-big",
461 EventType::Evidence,
462 Author::Agent,
463 Source::Chat,
464 format!("Evidence #{i}: {}", "lorem ipsum ".repeat(50)),
465 );
466 db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
467 db::index_event(&conn, &ev).unwrap();
468 }
469 let pack = assemble(&conn, "tj-big", PackMode::Full).unwrap();
470 assert!(
471 pack.text.len() <= 12 * 1024,
472 "pack must stay under ~12KB; got {} bytes",
473 pack.text.len()
474 );
475 assert!(pack.metadata.truncated, "metadata.truncated must be true");
476 assert!(pack.text.contains("truncated to fit pack budget"));
477 }
478
479 #[test]
480 fn corrected_events_appear_with_correction_event_type() {
481 use crate::db;
482 use crate::event::*;
483 use tempfile::TempDir;
484
485 let d = TempDir::new().unwrap();
486 let conn = db::open(d.path().join("s.sqlite")).unwrap();
487 let mut open_e = Event::new(
488 "tj-co",
489 EventType::Open,
490 Author::User,
491 Source::Cli,
492 "x".into(),
493 );
494 open_e.meta = serde_json::json!({"title": "Corr"});
495 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
496 db::index_event(&conn, &open_e).unwrap();
497
498 let bad = Event::new(
499 "tj-co",
500 EventType::Finding,
501 Author::Classifier,
502 Source::Hook,
503 "Migration done (wrong)".into(),
504 );
505 db::upsert_task_from_event(&conn, &bad, "feedface").unwrap();
506 db::index_event(&conn, &bad).unwrap();
507
508 let mut corr = Event::new(
509 "tj-co",
510 EventType::Correction,
511 Author::User,
512 Source::Cli,
513 "Migration NOT done; finding was wrong".into(),
514 );
515 corr.corrects = Some(bad.event_id.clone());
516 db::upsert_task_from_event(&conn, &corr, "feedface").unwrap();
517 db::index_event(&conn, &corr).unwrap();
518
519 let pack = assemble(&conn, "tj-co", PackMode::Full).unwrap();
520 assert!(pack.text.contains("[correction]"));
521 assert!(pack.text.contains("Migration NOT done"));
522 }
523
524 #[test]
525 fn suggested_events_get_question_mark_marker_in_pack() {
526 use crate::db;
527 use crate::event::*;
528 use tempfile::TempDir;
529
530 let d = TempDir::new().unwrap();
531 let conn = db::open(d.path().join("s.sqlite")).unwrap();
532 let mut open_e = Event::new(
533 "tj-q",
534 EventType::Open,
535 Author::User,
536 Source::Cli,
537 "x".into(),
538 );
539 open_e.meta = serde_json::json!({"title": "Q"});
540 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
541 db::index_event(&conn, &open_e).unwrap();
542
543 let mut suggested = Event::new(
544 "tj-q",
545 EventType::Decision,
546 Author::Classifier,
547 Source::Hook,
548 "Adopt Rust".into(),
549 );
550 suggested.status = EventStatus::Suggested;
551 db::upsert_task_from_event(&conn, &suggested, "feedface").unwrap();
552 db::index_event(&conn, &suggested).unwrap();
553
554 let pack = assemble(&conn, "tj-q", PackMode::Full).unwrap();
555 let recent_pos = pack.text.find("## Recent events").unwrap();
556 let recent_section = &pack.text[recent_pos..];
557 assert!(
558 recent_section.contains("[?]"),
559 "suggested event must show [?] marker in Recent events:\n{recent_section}"
560 );
561 }
562
563 #[test]
564 fn pack_renders_recent_events_full_mode() {
565 use crate::db;
566 use crate::event::*;
567 use tempfile::TempDir;
568
569 let d = TempDir::new().unwrap();
570 let conn = db::open(d.path().join("s.sqlite")).unwrap();
571 let mut open_e = Event::new(
572 "tj-re",
573 EventType::Open,
574 Author::User,
575 Source::Cli,
576 "x".into(),
577 );
578 open_e.meta = serde_json::json!({"title": "Recent"});
579 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
580 db::index_event(&conn, &open_e).unwrap();
581 for i in 0..6 {
582 let e = Event::new(
583 "tj-re",
584 EventType::Hypothesis,
585 Author::Agent,
586 Source::Chat,
587 format!("hypothesis {i}"),
588 );
589 db::upsert_task_from_event(&conn, &e, "feedface").unwrap();
590 db::index_event(&conn, &e).unwrap();
591 }
592
593 let pack = assemble(&conn, "tj-re", PackMode::Full).unwrap();
594 assert!(pack.text.contains("## Recent events"));
595 let count = pack.text.matches("[hypothesis]").count();
596 assert!(
597 count >= 5,
598 "expected >=5 hypotheses, got {count} in {}",
599 pack.text
600 );
601 }
602
603 #[test]
604 fn pack_renders_evidence_section() {
605 use crate::db;
606 use crate::event::*;
607 use tempfile::TempDir;
608
609 let d = TempDir::new().unwrap();
610 let conn = db::open(d.path().join("s.sqlite")).unwrap();
611 let mut open_e = Event::new(
612 "tj-ev",
613 EventType::Open,
614 Author::User,
615 Source::Cli,
616 "x".into(),
617 );
618 open_e.meta = serde_json::json!({"title": "Ev"});
619 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
620 db::index_event(&conn, &open_e).unwrap();
621
622 let mut ev = Event::new(
623 "tj-ev",
624 EventType::Evidence,
625 Author::Agent,
626 Source::Chat,
627 "Hook startup at 12ms vs 380ms node".into(),
628 );
629 ev.evidence_strength = Some(EvidenceStrength::Strong);
630 db::upsert_task_from_event(&conn, &ev, "feedface").unwrap();
631 db::index_event(&conn, &ev).unwrap();
632
633 let pack = assemble(&conn, "tj-ev", PackMode::Full).unwrap();
634 assert!(pack.text.contains("## Evidence"));
635 assert!(pack.text.contains("12ms"));
636 assert!(pack.text.contains("(strong)"));
637 }
638
639 #[test]
640 fn pack_renders_rejected_options() {
641 use crate::db;
642 use crate::event::*;
643 use tempfile::TempDir;
644
645 let d = TempDir::new().unwrap();
646 let conn = db::open(d.path().join("s.sqlite")).unwrap();
647 let mut open_e = Event::new(
648 "tj-r",
649 EventType::Open,
650 Author::User,
651 Source::Cli,
652 "x".into(),
653 );
654 open_e.meta = serde_json::json!({"title": "Rej"});
655 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
656 db::index_event(&conn, &open_e).unwrap();
657
658 let rej = Event::new(
659 "tj-r",
660 EventType::Rejection,
661 Author::Agent,
662 Source::Chat,
663 "TypeScript: loses single-binary distribution".into(),
664 );
665 db::upsert_task_from_event(&conn, &rej, "feedface").unwrap();
666 db::index_event(&conn, &rej).unwrap();
667
668 let pack = assemble(&conn, "tj-r", PackMode::Full).unwrap();
669 assert!(pack.text.contains("## Rejected"));
670 assert!(pack.text.contains("TypeScript"));
671 }
672
673 #[test]
674 fn pack_renders_active_decisions() {
675 use crate::db;
676 use crate::event::*;
677 use tempfile::TempDir;
678
679 let d = TempDir::new().unwrap();
680 let conn = db::open(d.path().join("s.sqlite")).unwrap();
681 let mut open_e = Event::new(
682 "tj-ad",
683 EventType::Open,
684 Author::User,
685 Source::Cli,
686 "x".into(),
687 );
688 open_e.meta = serde_json::json!({"title": "Decisions test"});
689 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
690 db::index_event(&conn, &open_e).unwrap();
691
692 let dec = Event::new(
693 "tj-ad",
694 EventType::Decision,
695 Author::Agent,
696 Source::Chat,
697 "Adopt Rust".into(),
698 );
699 db::upsert_task_from_event(&conn, &dec, "feedface").unwrap();
700 db::index_event(&conn, &dec).unwrap();
701
702 let pack = assemble(&conn, "tj-ad", PackMode::Full).unwrap();
703 assert!(
704 pack.text.contains("## Active decisions"),
705 "missing section: {}",
706 pack.text
707 );
708 assert!(
709 pack.text.contains("Adopt Rust"),
710 "decision text missing: {}",
711 pack.text
712 );
713 }
714
715 #[test]
716 fn assemble_includes_lifecycle_history() {
717 use crate::db;
718 use crate::event::*;
719 use tempfile::TempDir;
720
721 let d = TempDir::new().unwrap();
722 let conn = db::open(d.path().join("s.sqlite")).unwrap();
723
724 let mut open_e = Event::new(
725 "tj-l",
726 EventType::Open,
727 Author::User,
728 Source::Cli,
729 "x".into(),
730 );
731 open_e.meta = serde_json::json!({"title": "Lifecycle"});
732 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
733 db::index_event(&conn, &open_e).unwrap();
734
735 let close_e = Event::new(
736 "tj-l",
737 EventType::Close,
738 Author::User,
739 Source::Cli,
740 "done".into(),
741 );
742 db::upsert_task_from_event(&conn, &close_e, "feedface").unwrap();
743 db::index_event(&conn, &close_e).unwrap();
744
745 let pack = assemble(&conn, "tj-l", PackMode::Full).unwrap();
746 assert!(pack.text.contains("## Lifecycle"));
747 assert!(pack.text.contains("opened"));
748 assert!(pack.text.contains("closed"));
749 }
750
751 #[test]
752 fn assemble_header_only_compact() {
753 use crate::db;
754 use crate::event::*;
755 use tempfile::TempDir;
756
757 let d = TempDir::new().unwrap();
758 let conn = db::open(d.path().join("s.sqlite")).unwrap();
759
760 let mut open_e = Event::new(
761 "tj-h",
762 EventType::Open,
763 Author::User,
764 Source::Cli,
765 "x".into(),
766 );
767 open_e.meta = serde_json::json!({"title": "Header test"});
768 db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap();
769 db::index_event(&conn, &open_e).unwrap();
770
771 let pack = assemble(&conn, "tj-h", PackMode::Compact).unwrap();
772 assert!(
773 pack.text.contains("# Header test"),
774 "header missing: {}",
775 pack.text
776 );
777 assert!(
778 pack.text.contains("status: open"),
779 "status missing: {}",
780 pack.text
781 );
782 assert_eq!(pack.metadata.source_event_count, 1);
783 assert!(!pack.metadata.cache_hit);
784 }
785}