Skip to main content

ai_memory/cli/
io.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_export`, `cmd_import`, `cmd_mine` migrations.
5
6use crate::cli::CliOutput;
7use crate::{config, db, identity, mine, models, validate};
8use anyhow::Result;
9use chrono::{Duration, Utc};
10use clap::Args;
11use models::Tier;
12use std::path::{Path, PathBuf};
13
14#[derive(Args)]
15pub struct ImportArgs {
16    /// Trust `metadata.agent_id` in imported JSON (default: restamp with caller's id).
17    /// Only use this when importing a JSON export you fully trust (e.g., your own backup).
18    #[arg(long, default_value_t = false)]
19    pub trust_source: bool,
20}
21
22#[derive(Args)]
23pub struct MineArgs {
24    /// Path to the export file or directory
25    pub path: PathBuf,
26    /// Export format: claude, chatgpt, slack
27    #[arg(long, short)]
28    pub format: String,
29    /// Namespace for imported memories (auto-detected if omitted)
30    #[arg(long, short)]
31    pub namespace: Option<String>,
32    /// Memory tier for imported memories
33    #[arg(long, short, default_value = "mid")]
34    pub tier: String,
35    /// Minimum message count to import a conversation
36    #[arg(long, default_value_t = 3)]
37    pub min_messages: usize,
38    /// Dry run — show what would be imported without writing
39    #[arg(long, default_value_t = false)]
40    pub dry_run: bool,
41}
42
43/// `export` handler. Dumps every memory + link as pretty JSON.
44pub fn export(db_path: &Path, out: &mut CliOutput<'_>) -> Result<()> {
45    let conn = db::open(db_path)?;
46    let memories = db::export_all(&conn)?;
47    let links = db::export_links(&conn)?;
48    writeln!(
49        out.stdout,
50        "{}",
51        serde_json::to_string_pretty(&serde_json::json!({
52            "memories": memories, "links": links, "count": memories.len(),
53            "exported_at": Utc::now().to_rfc3339(),
54        }))?
55    )?;
56    Ok(())
57}
58
59/// `import` handler. Reads JSON from `import_reader` (defaulting to
60/// stdin in production) and inserts into the DB.
61pub fn import(
62    db_path: &Path,
63    args: &ImportArgs,
64    json_out: bool,
65    cli_agent_id: Option<&str>,
66    out: &mut CliOutput<'_>,
67) -> Result<()> {
68    let mut buf = String::new();
69    use std::io::Read as _;
70    std::io::stdin().read_to_string(&mut buf)?;
71    import_from_str(&buf, db_path, args, json_out, cli_agent_id, out)
72}
73
74/// Stdin-decoupled half of `import`. Tests call this directly with a
75/// literal payload instead of redirecting the process's stdin.
76pub(crate) fn import_from_str(
77    payload: &str,
78    db_path: &Path,
79    args: &ImportArgs,
80    json_out: bool,
81    cli_agent_id: Option<&str>,
82    out: &mut CliOutput<'_>,
83) -> Result<()> {
84    let data: serde_json::Value = serde_json::from_str(payload)?;
85    let memories: Vec<models::Memory> =
86        serde_json::from_value(data.get("memories").cloned().unwrap_or_default())?;
87    let links: Vec<models::MemoryLink> =
88        serde_json::from_value(data.get("links").cloned().unwrap_or_default()).unwrap_or_default();
89
90    let caller_id = identity::resolve_agent_id(cli_agent_id, None)?;
91
92    let conn = db::open(db_path)?;
93    let mut imported = 0usize;
94    let mut restamped = 0usize;
95    let mut errors = Vec::new();
96    for mut mem in memories {
97        if !args.trust_source {
98            let original = mem
99                .metadata
100                .get("agent_id")
101                .and_then(serde_json::Value::as_str)
102                .map(ToString::to_string);
103            if let Some(obj) = mem.metadata.as_object_mut() {
104                obj.insert(
105                    "agent_id".to_string(),
106                    serde_json::Value::String(caller_id.clone()),
107                );
108                if let Some(orig) = original.as_ref()
109                    && orig.as_str() != caller_id
110                {
111                    obj.insert(
112                        "imported_from_agent_id".to_string(),
113                        serde_json::Value::String(orig.clone()),
114                    );
115                    restamped += 1;
116                }
117            }
118        }
119        if let Err(e) = validate::validate_memory(&mem) {
120            errors.push(format!("{}: {}", mem.id, e));
121            continue;
122        }
123        match db::insert(&conn, &mem) {
124            Ok(_) => imported += 1,
125            Err(e) => errors.push(format!("{}: {}", mem.id, e)),
126        }
127    }
128    for link in links {
129        if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
130            continue;
131        }
132        let _ = db::create_link(&conn, &link.source_id, &link.target_id, &link.relation);
133    }
134    if json_out {
135        writeln!(
136            out.stdout,
137            "{}",
138            serde_json::json!({
139                "imported": imported,
140                "restamped": restamped,
141                "trusted_source": args.trust_source,
142                "errors": errors
143            })
144        )?;
145    } else {
146        writeln!(
147            out.stdout,
148            "imported: {imported} (restamped agent_id on {restamped})"
149        )?;
150        if args.trust_source {
151            writeln!(
152                out.stderr,
153                "warning: --trust-source: agent_id from imported JSON was preserved as-is"
154            )?;
155        }
156        if !errors.is_empty() {
157            for e in &errors {
158                writeln!(out.stderr, "  {e}")?;
159            }
160        }
161    }
162    Ok(())
163}
164
165/// `mine` handler.
166#[allow(clippy::too_many_lines)]
167pub fn mine(
168    db_path: &Path,
169    args: MineArgs,
170    json_out: bool,
171    app_config: &config::AppConfig,
172    cli_agent_id: Option<&str>,
173    out: &mut CliOutput<'_>,
174) -> Result<()> {
175    let miner_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
176    let format = mine::Format::from_str(&args.format).ok_or_else(|| {
177        anyhow::anyhow!(
178            "invalid format: {} (use claude, chatgpt, slack)",
179            args.format
180        )
181    })?;
182    let tier = Tier::from_str(&args.tier)
183        .ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
184    let namespace = args.namespace.unwrap_or_else(|| match format {
185        mine::Format::Claude => "claude-export".to_string(),
186        mine::Format::ChatGpt => "chatgpt-export".to_string(),
187        mine::Format::Slack => "slack-export".to_string(),
188    });
189
190    let path = std::path::Path::new(&args.path);
191
192    let conversations = match format {
193        mine::Format::Claude => mine::parse_claude(path)?,
194        mine::Format::ChatGpt => mine::parse_chatgpt(path)?,
195        mine::Format::Slack => mine::parse_slack(path)?,
196    };
197
198    let filtered: Vec<_> = conversations
199        .iter()
200        .filter(|c| c.messages.len() >= args.min_messages)
201        .collect();
202
203    if args.dry_run {
204        if json_out {
205            let items: Vec<serde_json::Value> = filtered
206                .iter()
207                .filter_map(|c| {
208                    mine::conversation_to_memory(c, format).map(|m| {
209                        serde_json::json!({
210                            "title": m.title,
211                            "content_length": m.content.len(),
212                            "messages": c.messages.len(),
213                            "source": m.source_format,
214                        })
215                    })
216                })
217                .collect();
218            writeln!(
219                out.stdout,
220                "{}",
221                serde_json::to_string_pretty(&serde_json::json!({
222                    "dry_run": true,
223                    "total_conversations": conversations.len(),
224                    "filtered": filtered.len(),
225                    "would_import": items.len(),
226                    "namespace": namespace,
227                    "tier": tier.as_str(),
228                    "memories": items,
229                }))?
230            )?;
231        } else {
232            writeln!(out.stdout, "Dry run — no memories will be stored\n")?;
233            writeln!(
234                out.stdout,
235                "Total conversations found: {}",
236                conversations.len()
237            )?;
238            writeln!(
239                out.stdout,
240                "After filter (>={} messages): {}",
241                args.min_messages,
242                filtered.len()
243            )?;
244            writeln!(out.stdout, "Namespace: {namespace}")?;
245            writeln!(out.stdout, "Tier: {tier}\n")?;
246            for c in &filtered {
247                if let Some(m) = mine::conversation_to_memory(c, format) {
248                    writeln!(
249                        out.stdout,
250                        "  {} ({} msgs, {} bytes)",
251                        m.title,
252                        c.messages.len(),
253                        m.content.len()
254                    )?;
255                }
256            }
257        }
258        return Ok(());
259    }
260
261    let conn = db::open(db_path)?;
262    let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
263    let now = Utc::now();
264
265    let mut imported = 0usize;
266    let mut skipped = 0usize;
267    let mut errors = 0usize;
268
269    conn.execute_batch("BEGIN")?;
270
271    for conv in &filtered {
272        let Some(mined) = mine::conversation_to_memory(conv, format) else {
273            skipped += 1;
274            continue;
275        };
276
277        let expires_at = app_config
278            .effective_ttl()
279            .ttl_for_tier(&tier)
280            .map(|s| (now + Duration::seconds(s)).to_rfc3339());
281
282        let mut metadata = models::default_metadata();
283        if let Some(obj) = metadata.as_object_mut() {
284            obj.insert(
285                "agent_id".to_string(),
286                serde_json::Value::String(miner_agent_id.clone()),
287            );
288            obj.insert(
289                "mined_from".to_string(),
290                serde_json::Value::String(format.source_tag().to_string()),
291            );
292        }
293        let mem = models::Memory {
294            id: uuid::Uuid::new_v4().to_string(),
295            tier: tier.clone(),
296            namespace: namespace.clone(),
297            title: mined.title,
298            content: mined.content,
299            tags: vec![format.source_tag().to_string()],
300            priority: 5,
301            confidence: 0.8,
302            source: mined.source_format,
303            access_count: 0,
304            created_at: mined.created_at.unwrap_or_else(|| now.to_rfc3339()),
305            updated_at: now.to_rfc3339(),
306            last_accessed_at: None,
307            expires_at,
308            metadata,
309        };
310
311        match db::insert(&conn, &mem) {
312            Ok(_) => imported += 1,
313            Err(e) => {
314                errors += 1;
315                writeln!(
316                    out.stderr,
317                    "warning: failed to store '{}': {}",
318                    mem.title, e
319                )?;
320            }
321        }
322
323        if imported.is_multiple_of(100) && imported > 0 {
324            conn.execute_batch("COMMIT")?;
325            conn.execute_batch("BEGIN")?;
326        }
327    }
328
329    conn.execute_batch("COMMIT")?;
330
331    if json_out {
332        writeln!(
333            out.stdout,
334            "{}",
335            serde_json::to_string(&serde_json::json!({
336                "imported": imported,
337                "skipped": skipped,
338                "errors": errors,
339                "total_conversations": conversations.len(),
340                "namespace": namespace,
341                "tier": tier.as_str(),
342            }))?
343        )?;
344    } else {
345        writeln!(
346            out.stdout,
347            "Imported {} memories from {} conversations (skipped: {}, errors: {})",
348            imported,
349            conversations.len(),
350            skipped,
351            errors
352        )?;
353        writeln!(out.stdout, "Namespace: {namespace}, Tier: {tier}")?;
354    }
355
356    Ok(())
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::cli::test_utils::{TestEnv, seed_memory};
363
364    // ---------------- export ------------------------------------------
365
366    #[test]
367    fn test_export_empty_db() {
368        let mut env = TestEnv::fresh();
369        let db = env.db_path.clone();
370        // Touch db path so db::open() materialises an empty schema
371        let _ = seed_memory(&db, "ns-init", "init", "init");
372        {
373            let mut out = env.output();
374            export(&db, &mut out).unwrap();
375        }
376        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
377        assert!(v["memories"].is_array());
378        assert!(v["links"].is_array());
379        assert!(v["count"].is_u64());
380        assert!(v["exported_at"].is_string());
381    }
382
383    #[test]
384    fn test_export_with_memories_includes_links() {
385        let mut env = TestEnv::fresh();
386        let db = env.db_path.clone();
387        let id1 = seed_memory(&db, "ns", "a", "content-a");
388        let id2 = seed_memory(&db, "ns", "b", "content-b");
389        let conn = db::open(&db).unwrap();
390        db::create_link(&conn, &id1, &id2, "relates").unwrap();
391        drop(conn);
392        {
393            let mut out = env.output();
394            export(&db, &mut out).unwrap();
395        }
396        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
397        let mems = v["memories"].as_array().unwrap();
398        assert_eq!(mems.len(), 2);
399        let links = v["links"].as_array().unwrap();
400        assert_eq!(links.len(), 1);
401    }
402
403    #[test]
404    fn test_export_pretty_printed_json() {
405        let mut env = TestEnv::fresh();
406        let db = env.db_path.clone();
407        let _ = seed_memory(&db, "ns", "x", "y");
408        {
409            let mut out = env.output();
410            export(&db, &mut out).unwrap();
411        }
412        // Pretty-printed JSON has at least one newline + 2-space indent.
413        let s = env.stdout_str();
414        assert!(s.contains('\n'));
415        assert!(s.contains("  \"memories\""));
416    }
417
418    // ---------------- import ------------------------------------------
419
420    fn export_payload_at(db_path: &Path) -> String {
421        let mut buf = Vec::<u8>::new();
422        let mut errbuf = Vec::<u8>::new();
423        let mut out = CliOutput::from_std(&mut buf, &mut errbuf);
424        export(db_path, &mut out).unwrap();
425        String::from_utf8(buf).unwrap()
426    }
427
428    #[test]
429    fn test_import_default_restamps_agent_id() {
430        // Source: a payload whose memories carry agent_id="other-agent"
431        let src = TestEnv::fresh();
432        let src_db = src.db_path.clone();
433        let id = seed_memory(&src_db, "ns", "src-title", "src-content");
434        {
435            let conn = db::open(&src_db).unwrap();
436            conn.execute(
437                "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'other-agent') WHERE id = ?1",
438                rusqlite::params![id],
439            )
440            .unwrap();
441        }
442        let payload = export_payload_at(&src_db);
443
444        let mut dst = TestEnv::fresh();
445        let dst_db = dst.db_path.clone();
446        let args = ImportArgs {
447            trust_source: false,
448        };
449        {
450            let mut out = dst.output();
451            import_from_str(
452                &payload,
453                &dst_db,
454                &args,
455                true,
456                Some("caller-agent"),
457                &mut out,
458            )
459            .unwrap();
460        }
461        let conn = db::open(&dst_db).unwrap();
462        let mem = db::get(&conn, &id).unwrap().unwrap();
463        assert_eq!(
464            mem.metadata.get("agent_id").and_then(|v| v.as_str()),
465            Some("caller-agent")
466        );
467        assert_eq!(
468            mem.metadata
469                .get("imported_from_agent_id")
470                .and_then(|v| v.as_str()),
471            Some("other-agent")
472        );
473    }
474
475    #[test]
476    fn test_import_trust_source_preserves_agent_id() {
477        let src = TestEnv::fresh();
478        let src_db = src.db_path.clone();
479        let id = seed_memory(&src_db, "ns", "tt", "cc");
480        {
481            let conn = db::open(&src_db).unwrap();
482            conn.execute(
483                "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'preserved-agent') WHERE id = ?1",
484                rusqlite::params![id],
485            )
486            .unwrap();
487        }
488        let payload = export_payload_at(&src_db);
489
490        let mut dst = TestEnv::fresh();
491        let dst_db = dst.db_path.clone();
492        let args = ImportArgs { trust_source: true };
493        {
494            let mut out = dst.output();
495            import_from_str(&payload, &dst_db, &args, false, Some("caller"), &mut out).unwrap();
496        }
497        let conn = db::open(&dst_db).unwrap();
498        let mem = db::get(&conn, &id).unwrap().unwrap();
499        assert_eq!(
500            mem.metadata.get("agent_id").and_then(|v| v.as_str()),
501            Some("preserved-agent")
502        );
503        assert!(dst.stderr_str().contains("trust-source"));
504    }
505
506    #[test]
507    fn test_import_invalid_memory_skipped_with_error() {
508        let mut dst = TestEnv::fresh();
509        let dst_db = dst.db_path.clone();
510        // Craft a payload with one valid + one invalid (empty title).
511        let payload = serde_json::json!({
512            "memories": [
513                {
514                    "id": "11111111-1111-1111-1111-111111111111",
515                    "tier": "mid",
516                    "namespace": "ns",
517                    "title": "",  // invalid: empty title
518                    "content": "c",
519                    "tags": [],
520                    "priority": 5,
521                    "confidence": 1.0,
522                    "source": "import",
523                    "access_count": 0,
524                    "created_at": "2026-01-01T00:00:00+00:00",
525                    "updated_at": "2026-01-01T00:00:00+00:00",
526                    "last_accessed_at": null,
527                    "expires_at": null,
528                    "metadata": {"agent_id": "x"}
529                },
530                {
531                    "id": "22222222-2222-2222-2222-222222222222",
532                    "tier": "mid",
533                    "namespace": "ns",
534                    "title": "valid-row",
535                    "content": "c",
536                    "tags": [],
537                    "priority": 5,
538                    "confidence": 1.0,
539                    "source": "import",
540                    "access_count": 0,
541                    "created_at": "2026-01-01T00:00:00+00:00",
542                    "updated_at": "2026-01-01T00:00:00+00:00",
543                    "last_accessed_at": null,
544                    "expires_at": null,
545                    "metadata": {"agent_id": "x"}
546                }
547            ],
548            "links": [],
549            "count": 2,
550            "exported_at": "2026-01-01T00:00:00+00:00"
551        })
552        .to_string();
553        let args = ImportArgs { trust_source: true };
554        {
555            let mut out = dst.output();
556            import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
557        }
558        let v: serde_json::Value = serde_json::from_str(dst.stdout_str().trim()).unwrap();
559        assert_eq!(v["imported"].as_u64().unwrap(), 1);
560        let errs = v["errors"].as_array().unwrap();
561        assert!(!errs.is_empty(), "expected at least one error");
562    }
563
564    #[test]
565    fn test_import_invalid_link_skipped() {
566        let mut dst = TestEnv::fresh();
567        let dst_db = dst.db_path.clone();
568        // Seed two valid memories so the link-target exists, then attach
569        // a syntactically-invalid link entry.
570        let id1 = seed_memory(&dst_db, "ns", "a", "ca");
571        let id2 = seed_memory(&dst_db, "ns", "b", "cb");
572        let payload = serde_json::json!({
573            "memories": [],
574            "links": [
575                { "source_id": id1, "target_id": id2, "relation": "" },
576                { "source_id": id1, "target_id": id2, "relation": "supersedes" }
577            ],
578            "count": 0,
579            "exported_at": "2026-01-01T00:00:00+00:00"
580        })
581        .to_string();
582        let args = ImportArgs { trust_source: true };
583        {
584            let mut out = dst.output();
585            import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
586        }
587        let v: serde_json::Value = serde_json::from_str(dst.stdout_str().trim()).unwrap();
588        assert_eq!(v["imported"].as_u64().unwrap(), 0);
589    }
590
591    #[test]
592    fn test_import_roundtrip_export_import_preserves_data() {
593        let src = TestEnv::fresh();
594        let src_db = src.db_path.clone();
595        let _id = seed_memory(&src_db, "rt-ns", "rt-title", "rt-content");
596        let payload = export_payload_at(&src_db);
597
598        let mut dst = TestEnv::fresh();
599        let dst_db = dst.db_path.clone();
600        let args = ImportArgs { trust_source: true };
601        {
602            let mut out = dst.output();
603            import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
604        }
605        let conn = db::open(&dst_db).unwrap();
606        let all = db::export_all(&conn).unwrap();
607        assert_eq!(all.len(), 1);
608        assert_eq!(all[0].title, "rt-title");
609        assert_eq!(all[0].content, "rt-content");
610        assert_eq!(all[0].namespace, "rt-ns");
611    }
612
613    // ---------------- mine --------------------------------------------
614
615    fn write_minimal_claude_export(dir: &Path) -> PathBuf {
616        // Claude export shape: JSONL — one conversation per line.
617        let conv1 = serde_json::json!({
618            "uuid": "conv-1",
619            "name": "Conv with 5 messages",
620            "created_at": "2026-01-01T00:00:00.000Z",
621            "updated_at": "2026-01-01T00:00:00.000Z",
622            "chat_messages": [
623                { "uuid": "m1", "text": "hello", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" },
624                { "uuid": "m2", "text": "hi there", "sender": "assistant", "created_at": "2026-01-01T00:00:00.000Z" },
625                { "uuid": "m3", "text": "how are you", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" },
626                { "uuid": "m4", "text": "fine thanks", "sender": "assistant", "created_at": "2026-01-01T00:00:00.000Z" },
627                { "uuid": "m5", "text": "ok bye", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" }
628            ]
629        });
630        let conv2 = serde_json::json!({
631            "uuid": "conv-2",
632            "name": "Short Conv",
633            "created_at": "2026-01-01T00:00:00.000Z",
634            "updated_at": "2026-01-01T00:00:00.000Z",
635            "chat_messages": [
636                { "uuid": "m6", "text": "ping", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" }
637            ]
638        });
639        let p = dir.join("claude.jsonl");
640        let body = format!("{}\n{}\n", conv1, conv2);
641        std::fs::write(&p, body).unwrap();
642        p
643    }
644
645    #[test]
646    fn test_mine_dry_run_writes_nothing() {
647        let mut env = TestEnv::fresh();
648        let db = env.db_path.clone();
649        let cfg = config::AppConfig::default();
650        let tmp = tempfile::tempdir().unwrap();
651        let claude_path = write_minimal_claude_export(tmp.path());
652        let args = MineArgs {
653            path: claude_path,
654            format: "claude".to_string(),
655            namespace: Some("mined-ns".to_string()),
656            tier: "mid".to_string(),
657            min_messages: 3,
658            dry_run: true,
659        };
660        {
661            let mut out = env.output();
662            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
663        }
664        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
665        assert_eq!(v["dry_run"].as_bool().unwrap(), true);
666        // No memory was written. Only attempt to open if the file exists
667        // (dry-run never touches the DB at all).
668        if db.exists() {
669            let conn = db::open(&db).unwrap();
670            let all = db::export_all(&conn).unwrap();
671            assert_eq!(all.len(), 0);
672        }
673    }
674
675    #[test]
676    fn test_mine_filters_by_min_messages() {
677        let mut env = TestEnv::fresh();
678        let db = env.db_path.clone();
679        let cfg = config::AppConfig::default();
680        let tmp = tempfile::tempdir().unwrap();
681        let claude_path = write_minimal_claude_export(tmp.path());
682        // min_messages=3 keeps Conv-1 (5 msgs) and drops Conv-2 (1 msg).
683        let args = MineArgs {
684            path: claude_path,
685            format: "claude".to_string(),
686            namespace: Some("mined-ns".to_string()),
687            tier: "mid".to_string(),
688            min_messages: 3,
689            dry_run: true,
690        };
691        {
692            let mut out = env.output();
693            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
694        }
695        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
696        assert_eq!(v["total_conversations"].as_u64().unwrap(), 2);
697        assert_eq!(v["filtered"].as_u64().unwrap(), 1);
698    }
699
700    // PR-9i — buffer coverage uplift. Targets the actual mine() write path
701    // (lines 261-356) and invalid format/tier error paths.
702
703    #[test]
704    fn pr9i_mine_actual_write_path_text() {
705        let mut env = TestEnv::fresh();
706        let db = env.db_path.clone();
707        let cfg = config::AppConfig::default();
708        let tmp = tempfile::tempdir().unwrap();
709        let claude_path = write_minimal_claude_export(tmp.path());
710        let args = MineArgs {
711            path: claude_path,
712            format: "claude".to_string(),
713            namespace: Some("mined-real".to_string()),
714            tier: "long".to_string(),
715            min_messages: 3,
716            dry_run: false,
717        };
718        {
719            let mut out = env.output();
720            mine(&db, args, false, &cfg, Some("miner-id"), &mut out).unwrap();
721        }
722        let s = env.stdout_str();
723        assert!(s.contains("Imported"));
724        assert!(s.contains("mined-real"));
725        // The conversation with >=3 messages was actually written.
726        let conn = db::open(&db).unwrap();
727        let all = db::export_all(&conn).unwrap();
728        let in_ns: Vec<&_> = all.iter().filter(|m| m.namespace == "mined-real").collect();
729        assert_eq!(
730            in_ns.len(),
731            1,
732            "expected exactly one mined memory in mined-real ns: {all:?}"
733        );
734        // agent_id is the miner; mined_from is the source format.
735        assert_eq!(
736            in_ns[0].metadata.get("agent_id").and_then(|v| v.as_str()),
737            Some("miner-id")
738        );
739        assert_eq!(
740            in_ns[0].metadata.get("mined_from").and_then(|v| v.as_str()),
741            Some("mine-claude")
742        );
743    }
744
745    #[test]
746    fn pr9i_mine_actual_write_path_json() {
747        let mut env = TestEnv::fresh();
748        let db = env.db_path.clone();
749        let cfg = config::AppConfig::default();
750        let tmp = tempfile::tempdir().unwrap();
751        let claude_path = write_minimal_claude_export(tmp.path());
752        let args = MineArgs {
753            path: claude_path,
754            format: "claude".to_string(),
755            namespace: Some("mined-json".to_string()),
756            tier: "mid".to_string(),
757            min_messages: 3,
758            dry_run: false,
759        };
760        {
761            let mut out = env.output();
762            mine(&db, args, true, &cfg, Some("miner-x"), &mut out).unwrap();
763        }
764        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
765        assert_eq!(v["namespace"].as_str().unwrap(), "mined-json");
766        assert_eq!(v["tier"].as_str().unwrap(), "mid");
767        assert!(v["imported"].as_u64().unwrap() >= 1);
768    }
769
770    #[test]
771    fn pr9i_mine_default_namespace_per_format() {
772        // Omit --namespace; defaults to "<format>-export".
773        let mut env = TestEnv::fresh();
774        let db = env.db_path.clone();
775        let cfg = config::AppConfig::default();
776        let tmp = tempfile::tempdir().unwrap();
777        let claude_path = write_minimal_claude_export(tmp.path());
778        let args = MineArgs {
779            path: claude_path,
780            format: "claude".to_string(),
781            namespace: None,
782            tier: "mid".to_string(),
783            min_messages: 3,
784            dry_run: true,
785        };
786        {
787            let mut out = env.output();
788            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
789        }
790        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
791        assert_eq!(v["namespace"].as_str().unwrap(), "claude-export");
792    }
793
794    #[test]
795    fn pr9i_mine_invalid_format_errors() {
796        let mut env = TestEnv::fresh();
797        let db = env.db_path.clone();
798        let cfg = config::AppConfig::default();
799        let tmp = tempfile::tempdir().unwrap();
800        let p = tmp.path().join("anything.jsonl");
801        std::fs::write(&p, "{}").unwrap();
802        let args = MineArgs {
803            path: p,
804            format: "myspace".to_string(), // not claude/chatgpt/slack
805            namespace: None,
806            tier: "mid".to_string(),
807            min_messages: 3,
808            dry_run: true,
809        };
810        let mut out = env.output();
811        let res = mine(&db, args, false, &cfg, Some("miner"), &mut out);
812        assert!(res.is_err());
813        assert!(res.unwrap_err().to_string().contains("invalid format"));
814    }
815
816    #[test]
817    fn pr9i_mine_invalid_tier_errors() {
818        let mut env = TestEnv::fresh();
819        let db = env.db_path.clone();
820        let cfg = config::AppConfig::default();
821        let tmp = tempfile::tempdir().unwrap();
822        let p = tmp.path().join("c.jsonl");
823        std::fs::write(&p, "{}").unwrap();
824        let args = MineArgs {
825            path: p,
826            format: "claude".to_string(),
827            namespace: None,
828            tier: "permanent".to_string(), // not short/mid/long
829            min_messages: 3,
830            dry_run: true,
831        };
832        let mut out = env.output();
833        let res = mine(&db, args, false, &cfg, Some("miner"), &mut out);
834        assert!(res.is_err());
835        assert!(res.unwrap_err().to_string().contains("invalid tier"));
836    }
837
838    #[test]
839    fn pr9i_mine_text_dry_run_lists_filtered_titles() {
840        // Text-mode dry_run prints conversation titles (lines 232-256).
841        let mut env = TestEnv::fresh();
842        let db = env.db_path.clone();
843        let cfg = config::AppConfig::default();
844        let tmp = tempfile::tempdir().unwrap();
845        let claude_path = write_minimal_claude_export(tmp.path());
846        let args = MineArgs {
847            path: claude_path,
848            format: "claude".to_string(),
849            namespace: Some("dry-text".to_string()),
850            tier: "short".to_string(),
851            min_messages: 3,
852            dry_run: true,
853        };
854        {
855            let mut out = env.output();
856            mine(&db, args, false, &cfg, Some("miner"), &mut out).unwrap();
857        }
858        let s = env.stdout_str();
859        assert!(s.contains("Dry run"));
860        assert!(s.contains("Total conversations found"));
861        assert!(s.contains("After filter"));
862        assert!(s.contains("dry-text"));
863        // The Claude conversation with name "Conv with 5 messages" must be
864        // listed in the filtered preview.
865        assert!(
866            s.contains("5 msgs") || s.contains("Conv with 5 messages"),
867            "expected conversation listing in dry-run text output: {s}"
868        );
869    }
870}