Skip to main content

recall_echo/
archive.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Conversation archival — converts conversations into persistent markdown archives.
6//!
7//! Supports two input paths:
8//! 1. **JSONL hook** — called directly by Claude Code SessionEnd hook (standalone)
9//! 2. **Pulse-null** — called with in-memory Messages (behind feature flag)
10//!
11//! Both converge into `archive_conversation()` which writes the markdown file,
12//! updates ARCHIVE.md, and appends to EPHEMERAL.md.
13
14use std::fmt::Write as _;
15use std::fs;
16use std::path::Path;
17
18use crate::config;
19use crate::conversation::{self, Conversation};
20use crate::ephemeral::{self, EphemeralEntry};
21use crate::error::RecallError;
22use crate::frontmatter::Frontmatter;
23use crate::summarize;
24use crate::tags;
25
26/// Session metadata provided by the caller.
27#[derive(Debug, Clone)]
28pub struct SessionMetadata {
29    pub session_id: String,
30    pub started_at: Option<String>,
31    pub ended_at: Option<String>,
32    pub entity_name: String,
33}
34
35/// Result of archiving a conversation — used by callers for graph ingestion.
36pub struct ArchiveResult {
37    pub log_number: u32,
38    pub full_content: String,
39    pub session_id: String,
40}
41
42/// Scan conversations/ for highest conversation-NNN number. Returns 0 if none.
43#[must_use]
44pub fn highest_conversation_number(conversations_dir: &Path) -> u32 {
45    let entries = match fs::read_dir(conversations_dir) {
46        Ok(e) => e,
47        Err(_) => return 0,
48    };
49
50    let mut max = 0u32;
51    for entry in entries.flatten() {
52        let name = entry.file_name();
53        let name = name.to_string_lossy();
54        if let Some(num_str) = name
55            .strip_prefix("conversation-")
56            .and_then(|s| s.strip_suffix(".md"))
57        {
58            if let Ok(n) = num_str.parse::<u32>() {
59                if n > max {
60                    max = n;
61                }
62            }
63        }
64    }
65
66    max
67}
68
69/// Append an entry to ARCHIVE.md (markdown table row).
70pub fn append_index(
71    archive_path: &Path,
72    log_num: u32,
73    date: &str,
74    session_id: &str,
75    topics: &[String],
76    message_count: u32,
77    duration: &str,
78) -> Result<(), RecallError> {
79    use std::io::Write;
80
81    let needs_header = if archive_path.exists() {
82        fs::read_to_string(archive_path)
83            .unwrap_or_default()
84            .trim()
85            .is_empty()
86    } else {
87        true
88    };
89
90    let mut file = fs::OpenOptions::new()
91        .create(true)
92        .append(true)
93        .open(archive_path)?;
94
95    if needs_header {
96        writeln!(file, "# Conversation Archive\n")?;
97        writeln!(
98            file,
99            "| # | Date | Session | Topics | Messages | Duration |"
100        )?;
101        writeln!(
102            file,
103            "|---|------|---------|--------|----------|----------|"
104        )?;
105    }
106
107    let topics_str = if topics.is_empty() {
108        "\u{2014}".to_string()
109    } else {
110        topics.join(", ")
111    };
112
113    writeln!(
114        file,
115        "| {log_num:03} | {date} | {session_id} | {topics_str} | {message_count} | {duration} |"
116    )?;
117
118    Ok(())
119}
120
121// ---------------------------------------------------------------------------
122// Core archive function — works with Conversation (universal path)
123// ---------------------------------------------------------------------------
124
125/// Archive a conversation from internal types.
126///
127/// This is the core archive function. All input paths (JSONL, pulse-null)
128/// converge here after converting to a Conversation.
129///
130/// Returns an ArchiveResult with the log number and content (for graph ingestion).
131pub fn archive_conversation(
132    memory_dir: &Path,
133    conv: &Conversation,
134    summary: &summarize::ConversationSummary,
135    source: &str,
136) -> Result<ArchiveResult, RecallError> {
137    let conversations_dir = memory_dir.join("conversations");
138    let archive_index = memory_dir.join("ARCHIVE.md");
139    let ephemeral_path = memory_dir.join("EPHEMERAL.md");
140
141    if !conversations_dir.exists() {
142        return Err(RecallError::NotInitialized(
143            "conversations/ directory not found. Run init first.".into(),
144        ));
145    }
146
147    // Skip empty sessions
148    if conv.user_message_count == 0 {
149        return Ok(ArchiveResult {
150            log_number: 0,
151            full_content: String::new(),
152            session_id: conv.session_id.clone(),
153        });
154    }
155
156    let next_num = highest_conversation_number(&conversations_dir) + 1;
157
158    let now = conversation::utc_now();
159    let date = conversation::date_from_timestamp(&now);
160    let duration = match (&conv.first_timestamp, &conv.last_timestamp) {
161        (Some(start), Some(end)) => conversation::calculate_duration(start, end),
162        _ => "unknown".to_string(),
163    };
164    let total_messages = conv.total_messages();
165
166    // Build frontmatter
167    let fm = Frontmatter {
168        log: next_num,
169        date: now.clone(),
170        session_id: conv.session_id.clone(),
171        message_count: total_messages,
172        duration: duration.clone(),
173        source: source.to_string(),
174        topics: summary.topics.clone(),
175    };
176
177    // Convert conversation to markdown
178    let md_body = conversation::conversation_to_markdown(conv, next_num);
179
180    // Extract tags
181    let conv_tags = tags::extract_tags(&conv.entries);
182    let tags_section = tags::format_tags_section(&conv_tags);
183
184    // Add summary section if available
185    let summary_section = if !summary.summary.is_empty() {
186        let mut s = format!("## Summary\n\n{}\n\n", summary.summary);
187        if !summary.decisions.is_empty() {
188            s.push_str("**Decisions**:\n");
189            for d in &summary.decisions {
190                let _ = writeln!(s, "- {d}");
191            }
192            s.push('\n');
193        }
194        if !summary.action_items.is_empty() {
195            s.push_str("**Action Items**:\n");
196            for a in &summary.action_items {
197                let _ = writeln!(s, "- {a}");
198            }
199            s.push('\n');
200        }
201        s
202    } else {
203        String::new()
204    };
205
206    let full_content = format!(
207        "{}\n\n{}{}\n{}",
208        fm.render(),
209        summary_section,
210        md_body,
211        tags_section
212    );
213
214    // Write conversation file
215    let conv_file = conversations_dir.join(format!("conversation-{next_num:03}.md"));
216    fs::write(&conv_file, &full_content)?;
217
218    // Append to ARCHIVE.md index
219    append_index(
220        &archive_index,
221        next_num,
222        &date,
223        &conv.session_id,
224        &summary.topics,
225        total_messages,
226        &duration,
227    )?;
228
229    // Append to EPHEMERAL.md
230    let entry = EphemeralEntry {
231        session_id: conv.session_id.clone(),
232        date: now,
233        duration,
234        message_count: total_messages,
235        archive_file: format!("conversation-{next_num:03}.md"),
236        summary: summary.summary.clone(),
237    };
238    ephemeral::append_entry(&ephemeral_path, &entry)?;
239    let cfg = config::load_from_dir(memory_dir);
240    ephemeral::trim_to_limit(&ephemeral_path, cfg.ephemeral.max_entries)?;
241
242    eprintln!("recall-echo: archived conversation-{next_num:03}.md ({total_messages} messages)");
243
244    Ok(ArchiveResult {
245        log_number: next_num,
246        full_content,
247        session_id: conv.session_id.clone(),
248    })
249}
250
251/// Ingest an archive result into the knowledge graph.
252pub fn graph_ingest(memory_dir: &Path, result: &ArchiveResult) {
253    if result.log_number == 0 {
254        return;
255    }
256    let rt = match client_runtime() {
257        Ok(rt) => rt,
258        Err(e) => {
259            eprintln!("recall-echo: graph runtime error: {e}");
260            return;
261        }
262    };
263    if let Err(e) = rt.block_on(crate::graph_bridge::ingest_into_graph(
264        memory_dir,
265        &result.full_content,
266        &result.session_id,
267        Some(result.log_number),
268    )) {
269        eprintln!("recall-echo: graph ingestion warning: {e}");
270    }
271}
272
273/// A runtime for a client-side command: a handful of socket round-trips, plus
274/// whatever the daemon does on our behalf. One thread is enough — the worker
275/// pool of a multi-thread runtime exists to be idle here.
276fn client_runtime() -> std::io::Result<tokio::runtime::Runtime> {
277    tokio::runtime::Builder::new_current_thread()
278        .enable_all()
279        .build()
280}
281
282/// The pipeline documents to sync, or `None` when auto-sync is off, no
283/// `docs_dir` is configured, or there is no graph to sync into.
284fn pipeline_docs_to_sync(memory_dir: &Path) -> Option<crate::graph::types::PipelineDocuments> {
285    let cfg = config::load_from_dir(memory_dir);
286    let pipeline = match cfg.pipeline {
287        Some(ref p) if p.auto_sync == Some(true) => p,
288        _ => return None,
289    };
290
291    let docs_dir = match pipeline.docs_dir {
292        Some(ref d) => {
293            let path = std::path::PathBuf::from(shellexpand_path(d));
294            if !path.exists() {
295                eprintln!(
296                    "recall-echo: pipeline docs_dir not found: {}",
297                    path.display()
298                );
299                return None;
300            }
301            path
302        }
303        None => {
304            eprintln!("recall-echo: pipeline auto_sync enabled but no docs_dir configured");
305            return None;
306        }
307    };
308
309    if !memory_dir.join("graph").exists() {
310        return None;
311    }
312    Some(read_pipeline_docs(&docs_dir))
313}
314
315/// Sync pipeline documents into the graph (if auto_sync enabled).
316///
317/// Non-blocking: logs warnings on failure but never fails the caller.
318pub fn pipeline_sync_on_archive(memory_dir: &Path) {
319    let Some(docs) = pipeline_docs_to_sync(memory_dir) else {
320        return;
321    };
322    let rt = match client_runtime() {
323        Ok(rt) => rt,
324        Err(e) => {
325            eprintln!("recall-echo: pipeline sync runtime error: {e}");
326            return;
327        }
328    };
329    report_pipeline_sync(rt.block_on(crate::graph_bridge::sync_pipeline_into_graph(
330        memory_dir, docs,
331    )));
332}
333
334/// Async version of pipeline_sync_on_archive for use in async contexts (pulse-null).
335#[cfg(feature = "pulse-null")]
336async fn pipeline_sync_on_archive_async(memory_dir: &Path) {
337    let Some(docs) = pipeline_docs_to_sync(memory_dir) else {
338        return;
339    };
340    report_pipeline_sync(crate::graph_bridge::sync_pipeline_into_graph(memory_dir, docs).await);
341}
342
343fn report_pipeline_sync(result: Result<crate::graph::types::PipelineSyncReport, RecallError>) {
344    match result {
345        Ok(report) => {
346            if report.entities_created > 0
347                || report.entities_updated > 0
348                || report.entities_archived > 0
349            {
350                eprintln!(
351                    "recall-echo: pipeline synced — +{} created, ~{} updated, -{} archived",
352                    report.entities_created, report.entities_updated, report.entities_archived
353                );
354            }
355        }
356        Err(e) => eprintln!("recall-echo: pipeline sync warning: {e}"),
357    }
358}
359
360fn read_pipeline_docs(docs_dir: &Path) -> crate::graph::types::PipelineDocuments {
361    crate::graph::types::PipelineDocuments {
362        learning: read_opt_file(docs_dir, "LEARNING.md"),
363        thoughts: read_opt_file(docs_dir, "THOUGHTS.md"),
364        curiosity: read_opt_file(docs_dir, "CURIOSITY.md"),
365        reflections: read_opt_file(docs_dir, "REFLECTIONS.md"),
366        praxis: read_opt_file(docs_dir, "PRAXIS.md"),
367    }
368}
369
370fn read_opt_file(dir: &Path, name: &str) -> String {
371    fs::read_to_string(dir.join(name)).unwrap_or_default()
372}
373
374fn shellexpand_path(path: &str) -> String {
375    if let Some(rest) = path.strip_prefix("~/") {
376        if let Ok(home) = std::env::var("HOME") {
377            return format!("{home}/{rest}");
378        }
379    }
380    path.to_string()
381}
382
383// ---------------------------------------------------------------------------
384// JSONL path — for Claude Code hooks (standalone, no LLM)
385// ---------------------------------------------------------------------------
386
387/// Archive a session from a JSONL transcript file.
388///
389/// Parses JSONL, generates algorithmic summary, archives, and optionally
390/// ingests into the knowledge graph. Both graph steps are daemon requests, so
391/// the hook pays for one warm store and one embedding-model load, not two.
392pub fn archive_from_jsonl(
393    base_dir: &Path,
394    session_id: &str,
395    transcript_path: &str,
396) -> Result<u32, RecallError> {
397    let conv = crate::jsonl::parse_transcript(transcript_path, session_id)?;
398    let summary = summarize::algorithmic_summary(&conv);
399    let result = archive_conversation(base_dir, &conv, &summary, "jsonl")?;
400    let log_number = result.log_number;
401
402    graph_ingest(base_dir, &result);
403    pipeline_sync_on_archive(base_dir);
404
405    Ok(log_number)
406}
407
408/// Main archive-session flow, called from the SessionEnd hook.
409/// Reads hook input from stdin.
410pub fn run_from_hook() -> Result<(), RecallError> {
411    let hook_input = crate::jsonl::read_hook_input()?;
412    run_with_hook_input(&hook_input)
413}
414
415/// Archive the session named by a hook input.
416///
417/// Sessions run with --no-session-persistence never write a transcript.
418/// A missing file is a normal no-op for the hook, not an error — failing
419/// here makes the entire `claude -p` invocation exit nonzero.
420pub fn run_with_hook_input(hook_input: &crate::jsonl::HookInput) -> Result<(), RecallError> {
421    if !Path::new(&hook_input.transcript_path).exists() {
422        eprintln!(
423            "recall-echo: no transcript at {} (session not persisted), nothing to archive",
424            hook_input.transcript_path
425        );
426        return Ok(());
427    }
428    let base_dir = crate::paths::claude_dir()?;
429    archive_from_jsonl(
430        &base_dir,
431        &hook_input.session_id,
432        &hook_input.transcript_path,
433    )?;
434    Ok(())
435}
436
437/// Archive all unarchived JSONL transcripts found under ~/.claude/projects/.
438pub fn archive_all_unarchived() -> Result<(), RecallError> {
439    let base = crate::paths::claude_dir()?;
440    archive_all_with_base(&base)
441}
442
443pub fn archive_all_with_base(base: &Path) -> Result<(), RecallError> {
444    let conversations_dir = base.join("conversations");
445    if !conversations_dir.exists() {
446        return Err(RecallError::NotInitialized(
447            "conversations/ directory not found. Run `recall-echo init` first.".into(),
448        ));
449    }
450
451    let archived_sessions = collect_archived_sessions(&conversations_dir);
452
453    let projects_dir = base.join("projects");
454    if !projects_dir.exists() {
455        eprintln!("No projects directory found \u{2014} nothing to archive.");
456        return Ok(());
457    }
458
459    let mut jsonl_files = find_jsonl_files(&projects_dir);
460    jsonl_files.sort_by_key(|p| {
461        fs::metadata(p)
462            .and_then(|m| m.modified())
463            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
464    });
465
466    let mut archived_count = 0;
467    let mut skipped_count = 0;
468
469    for jsonl_path in &jsonl_files {
470        let session_id = match jsonl_path.file_stem().and_then(|s| s.to_str()) {
471            Some(id) => id.to_string(),
472            None => continue,
473        };
474
475        if archived_sessions.contains(&session_id) {
476            skipped_count += 1;
477            continue;
478        }
479
480        let path_str = jsonl_path.to_string_lossy().to_string();
481        match archive_from_jsonl(base, &session_id, &path_str) {
482            Ok(_) => archived_count += 1,
483            Err(e) => {
484                eprintln!("recall-echo: skipping {session_id} \u{2014} {e}");
485            }
486        }
487    }
488
489    eprintln!(
490        "recall-echo: archived {archived_count} conversation{}, skipped {skipped_count} already archived",
491        if archived_count == 1 { "" } else { "s" }
492    );
493
494    Ok(())
495}
496
497/// Session ids that already have an archive in `conversations/`.
498///
499/// The archives themselves are the record of what has been captured — there is
500/// no separate ledger to fall out of step with them. Every importer checks this
501/// set before parsing anything, which is what makes re-running an import safe.
502#[must_use]
503pub fn collect_archived_sessions(conversations_dir: &Path) -> std::collections::HashSet<String> {
504    let mut sessions = std::collections::HashSet::new();
505    if let Ok(entries) = fs::read_dir(conversations_dir) {
506        for entry in entries.flatten() {
507            let name = entry.file_name();
508            let name = name.to_string_lossy();
509            if name.starts_with("conversation-") && name.ends_with(".md") {
510                if let Ok(content) = fs::read_to_string(entry.path()) {
511                    for line in content.lines().take(15) {
512                        if let Some(sid) = line.strip_prefix("session_id: ") {
513                            sessions.insert(sid.trim().trim_matches('"').to_string());
514                            break;
515                        }
516                    }
517                }
518            }
519        }
520    }
521    sessions
522}
523
524fn find_jsonl_files(dir: &Path) -> Vec<std::path::PathBuf> {
525    let mut files = Vec::new();
526    if let Ok(entries) = fs::read_dir(dir) {
527        for entry in entries.flatten() {
528            let path = entry.path();
529            if path.is_dir() {
530                files.extend(find_jsonl_files(&path));
531            } else if path.extension().is_some_and(|e| e == "jsonl") {
532                files.push(path);
533            }
534        }
535    }
536    files
537}
538
539// ---------------------------------------------------------------------------
540// Pulse-null path — behind feature flag
541// ---------------------------------------------------------------------------
542
543/// Archive a session from pulse-null in-memory messages.
544///
545/// Converts Messages to Conversation, uses LLM for summarization if available,
546/// and optionally ingests into the knowledge graph.
547#[cfg(feature = "pulse-null")]
548pub async fn archive_session(
549    memory_dir: &Path,
550    messages: &[pulse_system_types::llm::Message],
551    metadata: &SessionMetadata,
552    provider: Option<&dyn pulse_system_types::llm::LmProvider>,
553) -> Result<u32, RecallError> {
554    let mut conv = crate::pulse_null::messages_to_conversation(messages, &metadata.session_id);
555    conv.first_timestamp = metadata.started_at.clone();
556    conv.last_timestamp = metadata.ended_at.clone();
557
558    let summary = summarize::extract_with_fallback(provider, &conv).await;
559    let result = archive_conversation(memory_dir, &conv, &summary, "session")?;
560    let log_number = result.log_number;
561
562    // Graph ingestion (async path — no need for Runtime)
563    if log_number > 0 {
564        if let Err(e) = crate::graph_bridge::ingest_into_graph(
565            memory_dir,
566            &result.full_content,
567            &result.session_id,
568            Some(log_number),
569        )
570        .await
571        {
572            eprintln!("recall-echo: graph ingestion warning: {e}");
573        }
574        pipeline_sync_on_archive_async(memory_dir).await;
575    }
576
577    Ok(log_number)
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[test]
585    fn highest_from_empty_dir() {
586        let tmp = tempfile::tempdir().unwrap();
587        assert_eq!(highest_conversation_number(tmp.path()), 0);
588    }
589
590    #[test]
591    fn highest_from_sequential_files() {
592        let tmp = tempfile::tempdir().unwrap();
593        fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
594        fs::write(tmp.path().join("conversation-002.md"), "").unwrap();
595        fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
596        assert_eq!(highest_conversation_number(tmp.path()), 3);
597    }
598
599    #[test]
600    fn highest_with_gaps() {
601        let tmp = tempfile::tempdir().unwrap();
602        fs::write(tmp.path().join("conversation-001.md"), "").unwrap();
603        fs::write(tmp.path().join("conversation-010.md"), "").unwrap();
604        assert_eq!(highest_conversation_number(tmp.path()), 10);
605    }
606
607    #[test]
608    fn highest_ignores_non_matching() {
609        let tmp = tempfile::tempdir().unwrap();
610        fs::write(tmp.path().join("conversation-003.md"), "").unwrap();
611        fs::write(tmp.path().join("notes.md"), "").unwrap();
612        fs::write(tmp.path().join("conversation-bad.md"), "").unwrap();
613        assert_eq!(highest_conversation_number(tmp.path()), 3);
614    }
615
616    #[test]
617    fn append_index_creates_header_and_appends() {
618        let tmp = tempfile::tempdir().unwrap();
619        let index = tmp.path().join("ARCHIVE.md");
620
621        append_index(
622            &index,
623            1,
624            "2026-03-05",
625            "abc123",
626            &["auth".to_string()],
627            34,
628            "45m",
629        )
630        .unwrap();
631        append_index(
632            &index,
633            2,
634            "2026-03-05",
635            "def456",
636            &["ci".to_string(), "tests".to_string()],
637            22,
638            "20m",
639        )
640        .unwrap();
641
642        let content = fs::read_to_string(&index).unwrap();
643        assert!(content.contains("# Conversation Archive"));
644        assert!(content.contains("| 001 | 2026-03-05 | abc123 | auth | 34 | 45m |"));
645        assert!(content.contains("| 002 | 2026-03-05 | def456 | ci, tests | 22 | 20m |"));
646    }
647
648    #[test]
649    fn append_index_to_existing_file() {
650        let tmp = tempfile::tempdir().unwrap();
651        let index = tmp.path().join("ARCHIVE.md");
652        fs::write(
653            &index,
654            "# Conversation Archive\n\n| # | Date | Session | Topics | Messages | Duration |\n|---|------|---------|--------|----------|----------|\n| 001 | 2026-03-05 | abc | test | 10 | 5m |\n",
655        )
656        .unwrap();
657
658        append_index(&index, 2, "2026-03-05", "def", &[], 20, "10m").unwrap();
659
660        let content = fs::read_to_string(&index).unwrap();
661        assert!(content.contains("| 002 | 2026-03-05 | def | \u{2014} | 20 | 10m |"));
662        assert_eq!(content.matches("# Conversation Archive").count(), 1);
663    }
664
665    #[test]
666    fn archive_conversation_basic() {
667        let tmp = tempfile::tempdir().unwrap();
668        let memory = tmp.path();
669        fs::create_dir_all(memory.join("conversations")).unwrap();
670
671        let conv = Conversation {
672            session_id: "test-abc".to_string(),
673            first_timestamp: Some("2026-03-05T14:30:00Z".to_string()),
674            last_timestamp: Some("2026-03-05T15:00:00Z".to_string()),
675            user_message_count: 1,
676            assistant_message_count: 1,
677            entries: vec![
678                conversation::ConversationEntry::UserMessage("Let's build something".to_string()),
679                conversation::ConversationEntry::AssistantText("Sure, let's do it.".to_string()),
680            ],
681        };
682
683        let summary = summarize::ConversationSummary {
684            summary: "Built something cool".to_string(),
685            topics: vec!["building".to_string()],
686            decisions: vec![],
687            action_items: vec![],
688        };
689
690        let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
691        assert_eq!(result.log_number, 1);
692        assert!(memory.join("conversations/conversation-001.md").exists());
693
694        let content = fs::read_to_string(memory.join("conversations/conversation-001.md")).unwrap();
695        assert!(content.contains("session_id: \"test-abc\""));
696        assert!(content.contains("source: \"test\""));
697        assert!(content.contains("Built something cool"));
698    }
699
700    #[test]
701    fn archive_conversation_skips_empty() {
702        let tmp = tempfile::tempdir().unwrap();
703        let memory = tmp.path();
704        fs::create_dir_all(memory.join("conversations")).unwrap();
705
706        let conv = Conversation::new("empty");
707        let summary = summarize::ConversationSummary::default();
708
709        let result = archive_conversation(memory, &conv, &summary, "test").unwrap();
710        assert_eq!(result.log_number, 0);
711    }
712
713    #[test]
714    fn hook_missing_transcript_exits_ok() {
715        let hook_input = crate::jsonl::HookInput {
716            session_id: "no-persist".into(),
717            transcript_path: "/nonexistent/path/transcript.jsonl".into(),
718            _cwd: None,
719            _hook_event_name: None,
720        };
721        // --no-session-persistence sessions have no transcript: must be Ok, not Err.
722        assert!(run_with_hook_input(&hook_input).is_ok());
723    }
724}