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::models::ConfidenceSource;
8use crate::{config, db, identity, mine, models, validate};
9use anyhow::Result;
10use chrono::{Duration, Utc};
11use clap::Args;
12use models::Tier;
13use std::path::{Path, PathBuf};
14
15#[derive(Args)]
16pub struct ImportArgs {
17    /// Trust `metadata.agent_id` in imported JSON (default: restamp with caller's id).
18    /// Only use this when importing a JSON export you fully trust (e.g., your own backup).
19    #[arg(long, default_value_t = false)]
20    pub trust_source: bool,
21}
22
23#[derive(Args)]
24pub struct MineArgs {
25    /// Path to the export file or directory
26    pub path: PathBuf,
27    /// Export format: claude, chatgpt, slack
28    #[arg(long, short)]
29    pub format: String,
30    /// Namespace for imported memories (auto-detected if omitted)
31    #[arg(long, short)]
32    pub namespace: Option<String>,
33    /// Memory tier for imported memories
34    ///
35    /// `default_value` must be a literal at attribute-parse time, so
36    /// the wire string is kept here verbatim; it is byte-equal to
37    /// `crate::models::Tier::Mid.as_str()` (pm-v3.1 PR6 #1174 sweep —
38    /// raw tier literals are confined to the deserializer + clap
39    /// `default_value` attrs that cannot accept const expressions).
40    #[arg(long, short, default_value = "mid")]
41    pub tier: String,
42    /// Minimum message count to import a conversation
43    #[arg(long, default_value_t = 3)]
44    pub min_messages: usize,
45    /// Dry run — show what would be imported without writing
46    #[arg(long, default_value_t = false)]
47    pub dry_run: bool,
48}
49
50/// `export` handler. Dumps every memory + link as pretty JSON.
51pub fn export(db_path: &Path, out: &mut CliOutput<'_>) -> Result<()> {
52    let conn = db::open(db_path)?;
53    let memories = db::export_all(&conn)?;
54    let links = db::export_links(&conn)?;
55    writeln!(
56        out.stdout,
57        "{}",
58        serde_json::to_string_pretty(&serde_json::json!({
59            "memories": memories, "links": links, "count": memories.len(),
60            (crate::models::field_names::EXPORTED_AT): Utc::now().to_rfc3339(),
61        }))?
62    )?;
63    Ok(())
64}
65
66/// `import` handler. Reads JSON from `import_reader` (defaulting to
67/// stdin in production) and inserts into the DB.
68pub fn import(
69    db_path: &Path,
70    args: &ImportArgs,
71    json_out: bool,
72    cli_agent_id: Option<&str>,
73    out: &mut CliOutput<'_>,
74) -> Result<()> {
75    let mut buf = String::new();
76    use std::io::Read as _;
77    std::io::stdin().read_to_string(&mut buf)?;
78    import_from_str(&buf, db_path, args, json_out, cli_agent_id, out)
79}
80
81/// Stdin-decoupled half of `import`. Tests call this directly with a
82/// literal payload instead of redirecting the process's stdin.
83pub(crate) fn import_from_str(
84    payload: &str,
85    db_path: &Path,
86    args: &ImportArgs,
87    json_out: bool,
88    cli_agent_id: Option<&str>,
89    out: &mut CliOutput<'_>,
90) -> Result<()> {
91    let data: serde_json::Value = serde_json::from_str(payload)?;
92    let memories: Vec<models::Memory> =
93        serde_json::from_value(data.get("memories").cloned().unwrap_or_default())?;
94    let links: Vec<models::MemoryLink> =
95        serde_json::from_value(data.get("links").cloned().unwrap_or_default()).unwrap_or_default();
96
97    let caller_id = identity::resolve_agent_id(cli_agent_id, None)?;
98
99    let conn = db::open(db_path)?;
100    let mut imported = 0usize;
101    let mut restamped = 0usize;
102    let mut errors = Vec::new();
103    for mut mem in memories {
104        if !args.trust_source {
105            let original = mem
106                .metadata
107                .get("agent_id")
108                .and_then(serde_json::Value::as_str)
109                .map(ToString::to_string);
110            if let Some(obj) = mem.metadata.as_object_mut() {
111                obj.insert(
112                    "agent_id".to_string(),
113                    serde_json::Value::String(caller_id.clone()),
114                );
115                if let Some(orig) = original.as_ref()
116                    && orig.as_str() != caller_id
117                {
118                    obj.insert(
119                        crate::models::field_names::IMPORTED_FROM_AGENT_ID.to_string(),
120                        serde_json::Value::String(orig.clone()),
121                    );
122                    restamped += 1;
123                }
124            }
125        }
126        if let Err(e) = validate::validate_memory(&mem) {
127            errors.push(format!("{}: {}", mem.id, e));
128            continue;
129        }
130        match db::insert(&conn, &mem) {
131            Ok(_) => imported += 1,
132            Err(e) => errors.push(format!("{}: {}", mem.id, e)),
133        }
134    }
135    for link in links {
136        if validate::validate_link(&link.source_id, &link.target_id, link.relation.as_str())
137            .is_err()
138        {
139            continue;
140        }
141        let _ = db::create_link(
142            &conn,
143            &link.source_id,
144            &link.target_id,
145            link.relation.as_str(),
146        );
147    }
148    if json_out {
149        writeln!(
150            out.stdout,
151            "{}",
152            serde_json::json!({
153                "imported": imported,
154                "restamped": restamped,
155                "trusted_source": args.trust_source,
156                "errors": errors
157            })
158        )?;
159    } else {
160        writeln!(
161            out.stdout,
162            "imported: {imported} (restamped agent_id on {restamped})"
163        )?;
164        if args.trust_source {
165            writeln!(
166                out.stderr,
167                "warning: --trust-source: agent_id from imported JSON was preserved as-is"
168            )?;
169        }
170        if !errors.is_empty() {
171            for e in &errors {
172                writeln!(out.stderr, "  {e}")?;
173            }
174        }
175    }
176    Ok(())
177}
178
179/// `mine` handler.
180#[allow(clippy::too_many_lines)]
181pub fn mine(
182    db_path: &Path,
183    args: MineArgs,
184    json_out: bool,
185    app_config: &config::AppConfig,
186    cli_agent_id: Option<&str>,
187    out: &mut CliOutput<'_>,
188) -> Result<()> {
189    let miner_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
190    let format = mine::Format::from_str(&args.format).ok_or_else(|| {
191        anyhow::anyhow!(
192            "invalid format: {} (use claude, chatgpt, slack)",
193            args.format
194        )
195    })?;
196    let tier = Tier::from_str(&args.tier)
197        .ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
198    let namespace = args.namespace.unwrap_or_else(|| match format {
199        mine::Format::Claude => "claude-export".to_string(),
200        mine::Format::ChatGpt => "chatgpt-export".to_string(),
201        mine::Format::Slack => "slack-export".to_string(),
202    });
203
204    let path = std::path::Path::new(&args.path);
205
206    let conversations = match format {
207        mine::Format::Claude => mine::parse_claude(path)?,
208        mine::Format::ChatGpt => mine::parse_chatgpt(path)?,
209        mine::Format::Slack => mine::parse_slack(path)?,
210    };
211
212    let filtered: Vec<_> = conversations
213        .iter()
214        .filter(|c| c.messages.len() >= args.min_messages)
215        .collect();
216
217    if args.dry_run {
218        if json_out {
219            let items: Vec<serde_json::Value> = filtered
220                .iter()
221                .filter_map(|c| {
222                    mine::conversation_to_memory(c, format).map(|m| {
223                        serde_json::json!({
224                            "title": m.title,
225                            "content_length": m.content.len(),
226                            "messages": c.messages.len(),
227                            "source": m.source_format,
228                        })
229                    })
230                })
231                .collect();
232            writeln!(
233                out.stdout,
234                "{}",
235                serde_json::to_string_pretty(&serde_json::json!({
236                    "dry_run": true,
237                    "total_conversations": conversations.len(),
238                    "filtered": filtered.len(),
239                    "would_import": items.len(),
240                    "namespace": namespace,
241                    "tier": tier.as_str(),
242                    "memories": items,
243                }))?
244            )?;
245        } else {
246            writeln!(out.stdout, "Dry run — no memories will be stored\n")?;
247            writeln!(
248                out.stdout,
249                "Total conversations found: {}",
250                conversations.len()
251            )?;
252            writeln!(
253                out.stdout,
254                "After filter (>={} messages): {}",
255                args.min_messages,
256                filtered.len()
257            )?;
258            writeln!(out.stdout, "Namespace: {namespace}")?;
259            writeln!(out.stdout, "Tier: {tier}\n")?;
260            for c in &filtered {
261                if let Some(m) = mine::conversation_to_memory(c, format) {
262                    writeln!(
263                        out.stdout,
264                        "  {} ({} msgs, {} bytes)",
265                        m.title,
266                        c.messages.len(),
267                        m.content.len()
268                    )?;
269                }
270            }
271        }
272        return Ok(());
273    }
274
275    let conn = db::open(db_path)?;
276    let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
277    let now = Utc::now();
278
279    let mut imported = 0usize;
280    let mut skipped = 0usize;
281    let mut errors = 0usize;
282
283    conn.execute_batch("BEGIN")?;
284
285    for conv in &filtered {
286        let Some(mined) = mine::conversation_to_memory(conv, format) else {
287            skipped += 1;
288            continue;
289        };
290
291        let expires_at = app_config
292            .effective_ttl()
293            .ttl_for_tier(&tier)
294            .map(|s| (now + Duration::seconds(s)).to_rfc3339());
295
296        let mut metadata = models::default_metadata();
297        if let Some(obj) = metadata.as_object_mut() {
298            obj.insert(
299                "agent_id".to_string(),
300                serde_json::Value::String(miner_agent_id.clone()),
301            );
302            obj.insert(
303                "mined_from".to_string(),
304                serde_json::Value::String(format.source_tag().to_string()),
305            );
306        }
307        let mem = models::Memory {
308            id: uuid::Uuid::new_v4().to_string(),
309            tier: tier.clone(),
310            namespace: namespace.clone(),
311            title: mined.title,
312            content: mined.content,
313            tags: vec![format.source_tag().to_string()],
314            priority: 5,
315            confidence: 0.8,
316            source: mined.source_format,
317            access_count: 0,
318            created_at: mined.created_at.unwrap_or_else(|| now.to_rfc3339()),
319            updated_at: now.to_rfc3339(),
320            last_accessed_at: None,
321            expires_at,
322            metadata,
323            reflection_depth: 0,
324            memory_kind: crate::models::MemoryKind::Observation,
325            entity_id: None,
326            persona_version: None,
327            citations: Vec::new(),
328            source_uri: None,
329            source_span: None,
330            confidence_source: ConfidenceSource::CallerProvided,
331            confidence_signals: None,
332            confidence_decayed_at: None,
333            version: 1,
334        };
335
336        match db::insert(&conn, &mem) {
337            Ok(_) => imported += 1,
338            Err(e) => {
339                errors += 1;
340                writeln!(
341                    out.stderr,
342                    "warning: failed to store '{}': {}",
343                    mem.title, e
344                )?;
345            }
346        }
347
348        if imported.is_multiple_of(100) && imported > 0 {
349            conn.execute_batch(crate::storage::connection::SQL_COMMIT)?;
350            conn.execute_batch("BEGIN")?;
351        }
352    }
353
354    conn.execute_batch(crate::storage::connection::SQL_COMMIT)?;
355
356    if json_out {
357        writeln!(
358            out.stdout,
359            "{}",
360            serde_json::to_string(&serde_json::json!({
361                "imported": imported,
362                "skipped": skipped,
363                "errors": errors,
364                "total_conversations": conversations.len(),
365                "namespace": namespace,
366                "tier": tier.as_str(),
367            }))?
368        )?;
369    } else {
370        writeln!(
371            out.stdout,
372            "Imported {} memories from {} conversations (skipped: {}, errors: {})",
373            imported,
374            conversations.len(),
375            skipped,
376            errors
377        )?;
378        writeln!(out.stdout, "Namespace: {namespace}, Tier: {tier}")?;
379    }
380
381    Ok(())
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::cli::test_utils::{TestEnv, seed_memory};
388
389    // ---------------- export ------------------------------------------
390
391    #[test]
392    fn test_export_empty_db() {
393        let mut env = TestEnv::fresh();
394        let db = env.db_path.clone();
395        // Touch db path so db::open() materialises an empty schema
396        let _ = seed_memory(&db, "ns-init", "init", "init");
397        {
398            let mut out = env.output();
399            export(&db, &mut out).unwrap();
400        }
401        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
402        assert!(v["memories"].is_array());
403        assert!(v["links"].is_array());
404        assert!(v["count"].is_u64());
405        assert!(v["exported_at"].is_string());
406    }
407
408    #[test]
409    fn test_export_with_memories_includes_links() {
410        let mut env = TestEnv::fresh();
411        let db = env.db_path.clone();
412        let id1 = seed_memory(&db, "ns", "a", "content-a");
413        let id2 = seed_memory(&db, "ns", "b", "content-b");
414        let conn = db::open(&db).unwrap();
415        // v0.7.0 fix campaign R1-M2/M4 — relation must be in the
416        // closed-set; pre-fix value `"relates"` was silently accepted.
417        db::create_link(&conn, &id1, &id2, "related_to").unwrap();
418        drop(conn);
419        {
420            let mut out = env.output();
421            export(&db, &mut out).unwrap();
422        }
423        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
424        let mems = v["memories"].as_array().unwrap();
425        assert_eq!(mems.len(), 2);
426        let links = v["links"].as_array().unwrap();
427        assert_eq!(links.len(), 1);
428    }
429
430    #[test]
431    fn test_export_pretty_printed_json() {
432        let mut env = TestEnv::fresh();
433        let db = env.db_path.clone();
434        let _ = seed_memory(&db, "ns", "x", "y");
435        {
436            let mut out = env.output();
437            export(&db, &mut out).unwrap();
438        }
439        // Pretty-printed JSON has at least one newline + 2-space indent.
440        let s = env.stdout_str();
441        assert!(s.contains('\n'));
442        assert!(s.contains("  \"memories\""));
443    }
444
445    // ---------------- import ------------------------------------------
446
447    fn export_payload_at(db_path: &Path) -> String {
448        let mut buf = Vec::<u8>::new();
449        let mut errbuf = Vec::<u8>::new();
450        let mut out = CliOutput::from_std(&mut buf, &mut errbuf);
451        export(db_path, &mut out).unwrap();
452        String::from_utf8(buf).unwrap()
453    }
454
455    #[test]
456    fn test_import_default_restamps_agent_id() {
457        // Source: a payload whose memories carry agent_id="other-agent"
458        let src = TestEnv::fresh();
459        let src_db = src.db_path.clone();
460        let id = seed_memory(&src_db, "ns", "src-title", "src-content");
461        {
462            let conn = db::open(&src_db).unwrap();
463            conn.execute(
464                "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'other-agent') WHERE id = ?1",
465                rusqlite::params![id],
466            )
467            .unwrap();
468        }
469        let payload = export_payload_at(&src_db);
470
471        let mut dst = TestEnv::fresh();
472        let dst_db = dst.db_path.clone();
473        let args = ImportArgs {
474            trust_source: false,
475        };
476        {
477            let mut out = dst.output();
478            import_from_str(
479                &payload,
480                &dst_db,
481                &args,
482                true,
483                Some("caller-agent"),
484                &mut out,
485            )
486            .unwrap();
487        }
488        let conn = db::open(&dst_db).unwrap();
489        let mem = db::get(&conn, &id).unwrap().unwrap();
490        assert_eq!(
491            mem.metadata.get("agent_id").and_then(|v| v.as_str()),
492            Some("caller-agent")
493        );
494        assert_eq!(
495            mem.metadata
496                .get("imported_from_agent_id")
497                .and_then(|v| v.as_str()),
498            Some("other-agent")
499        );
500    }
501
502    #[test]
503    fn test_import_trust_source_preserves_agent_id() {
504        let src = TestEnv::fresh();
505        let src_db = src.db_path.clone();
506        let id = seed_memory(&src_db, "ns", "tt", "cc");
507        {
508            let conn = db::open(&src_db).unwrap();
509            conn.execute(
510                "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'preserved-agent') WHERE id = ?1",
511                rusqlite::params![id],
512            )
513            .unwrap();
514        }
515        let payload = export_payload_at(&src_db);
516
517        let mut dst = TestEnv::fresh();
518        let dst_db = dst.db_path.clone();
519        let args = ImportArgs { trust_source: true };
520        {
521            let mut out = dst.output();
522            import_from_str(&payload, &dst_db, &args, false, Some("caller"), &mut out).unwrap();
523        }
524        let conn = db::open(&dst_db).unwrap();
525        let mem = db::get(&conn, &id).unwrap().unwrap();
526        assert_eq!(
527            mem.metadata.get("agent_id").and_then(|v| v.as_str()),
528            Some("preserved-agent")
529        );
530        assert!(dst.stderr_str().contains("trust-source"));
531    }
532
533    #[test]
534    fn test_import_invalid_memory_skipped_with_error() {
535        let mut dst = TestEnv::fresh();
536        let dst_db = dst.db_path.clone();
537        // Craft a payload with one valid + one invalid (empty title).
538        let payload = serde_json::json!({
539            "memories": [
540                {
541                    "id": "11111111-1111-1111-1111-111111111111",
542                    "tier": Tier::Mid.as_str(),
543                    "namespace": "ns",
544                    "title": "",  // invalid: empty title
545                    "content": "c",
546                    "tags": [],
547                    "priority": 5,
548                    "confidence": 1.0,
549                    "source": "import",
550                    "access_count": 0,
551                    "created_at": "2026-01-01T00:00:00+00:00",
552                    "updated_at": "2026-01-01T00:00:00+00:00",
553                    "last_accessed_at": null,
554                    "expires_at": null,
555                    "metadata": {"agent_id": "x"}
556                },
557                {
558                    "id": "22222222-2222-2222-2222-222222222222",
559                    "tier": Tier::Mid.as_str(),
560                    "namespace": "ns",
561                    "title": "valid-row",
562                    "content": "c",
563                    "tags": [],
564                    "priority": 5,
565                    "confidence": 1.0,
566                    "source": "import",
567                    "access_count": 0,
568                    "created_at": "2026-01-01T00:00:00+00:00",
569                    "updated_at": "2026-01-01T00:00:00+00:00",
570                    "last_accessed_at": null,
571                    "expires_at": null,
572                    "metadata": {"agent_id": "x"}
573                }
574            ],
575            "links": [],
576            "count": 2,
577            "exported_at": "2026-01-01T00:00:00+00:00"
578        })
579        .to_string();
580        let args = ImportArgs { trust_source: true };
581        {
582            let mut out = dst.output();
583            import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
584        }
585        let v: serde_json::Value = serde_json::from_str(dst.stdout_str().trim()).unwrap();
586        assert_eq!(v["imported"].as_u64().unwrap(), 1);
587        let errs = v["errors"].as_array().unwrap();
588        assert!(!errs.is_empty(), "expected at least one error");
589    }
590
591    #[test]
592    fn test_import_invalid_link_skipped() {
593        let mut dst = TestEnv::fresh();
594        let dst_db = dst.db_path.clone();
595        // Seed two valid memories so the link-target exists, then attach
596        // a syntactically-invalid link entry.
597        let id1 = seed_memory(&dst_db, "ns", "a", "ca");
598        let id2 = seed_memory(&dst_db, "ns", "b", "cb");
599        let payload = serde_json::json!({
600            "memories": [],
601            "links": [
602                { "source_id": id1, "target_id": id2, "relation": "" },
603                { "source_id": id1, "target_id": id2, "relation": "supersedes" }
604            ],
605            "count": 0,
606            "exported_at": "2026-01-01T00:00:00+00:00"
607        })
608        .to_string();
609        let args = ImportArgs { trust_source: true };
610        {
611            let mut out = dst.output();
612            import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
613        }
614        let v: serde_json::Value = serde_json::from_str(dst.stdout_str().trim()).unwrap();
615        assert_eq!(v["imported"].as_u64().unwrap(), 0);
616    }
617
618    #[test]
619    fn test_import_roundtrip_export_import_preserves_data() {
620        let src = TestEnv::fresh();
621        let src_db = src.db_path.clone();
622        let _id = seed_memory(&src_db, "rt-ns", "rt-title", "rt-content");
623        let payload = export_payload_at(&src_db);
624
625        let mut dst = TestEnv::fresh();
626        let dst_db = dst.db_path.clone();
627        let args = ImportArgs { trust_source: true };
628        {
629            let mut out = dst.output();
630            import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
631        }
632        let conn = db::open(&dst_db).unwrap();
633        let all = db::export_all(&conn).unwrap();
634        assert_eq!(all.len(), 1);
635        assert_eq!(all[0].title, "rt-title");
636        assert_eq!(all[0].content, "rt-content");
637        assert_eq!(all[0].namespace, "rt-ns");
638    }
639
640    // ---------------- mine --------------------------------------------
641
642    fn write_minimal_claude_export(dir: &Path) -> PathBuf {
643        // Claude export shape: JSONL — one conversation per line.
644        let conv1 = serde_json::json!({
645            "uuid": "conv-1",
646            "name": "Conv with 5 messages",
647            "created_at": "2026-01-01T00:00:00.000Z",
648            "updated_at": "2026-01-01T00:00:00.000Z",
649            "chat_messages": [
650                { "uuid": "m1", "text": "hello", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" },
651                { "uuid": "m2", "text": "hi there", "sender": "assistant", "created_at": "2026-01-01T00:00:00.000Z" },
652                { "uuid": "m3", "text": "how are you", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" },
653                { "uuid": "m4", "text": "fine thanks", "sender": "assistant", "created_at": "2026-01-01T00:00:00.000Z" },
654                { "uuid": "m5", "text": "ok bye", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" }
655            ]
656        });
657        let conv2 = serde_json::json!({
658            "uuid": "conv-2",
659            "name": "Short Conv",
660            "created_at": "2026-01-01T00:00:00.000Z",
661            "updated_at": "2026-01-01T00:00:00.000Z",
662            "chat_messages": [
663                { "uuid": "m6", "text": "ping", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" }
664            ]
665        });
666        let p = dir.join("claude.jsonl");
667        let body = format!("{}\n{}\n", conv1, conv2);
668        std::fs::write(&p, body).unwrap();
669        p
670    }
671
672    #[test]
673    fn test_mine_dry_run_writes_nothing() {
674        let mut env = TestEnv::fresh();
675        let db = env.db_path.clone();
676        let cfg = config::AppConfig::default();
677        let tmp = tempfile::tempdir().unwrap();
678        let claude_path = write_minimal_claude_export(tmp.path());
679        let args = MineArgs {
680            path: claude_path,
681            format: "claude".to_string(),
682            namespace: Some("mined-ns".to_string()),
683            tier: Tier::Mid.as_str().to_string(),
684            min_messages: 3,
685            dry_run: true,
686        };
687        {
688            let mut out = env.output();
689            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
690        }
691        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
692        assert_eq!(v["dry_run"].as_bool().unwrap(), true);
693        // No memory was written. Only attempt to open if the file exists
694        // (dry-run never touches the DB at all).
695        if db.exists() {
696            let conn = db::open(&db).unwrap();
697            let all = db::export_all(&conn).unwrap();
698            assert_eq!(all.len(), 0);
699        }
700    }
701
702    #[test]
703    fn test_mine_filters_by_min_messages() {
704        let mut env = TestEnv::fresh();
705        let db = env.db_path.clone();
706        let cfg = config::AppConfig::default();
707        let tmp = tempfile::tempdir().unwrap();
708        let claude_path = write_minimal_claude_export(tmp.path());
709        // min_messages=3 keeps Conv-1 (5 msgs) and drops Conv-2 (1 msg).
710        let args = MineArgs {
711            path: claude_path,
712            format: "claude".to_string(),
713            namespace: Some("mined-ns".to_string()),
714            tier: Tier::Mid.as_str().to_string(),
715            min_messages: 3,
716            dry_run: true,
717        };
718        {
719            let mut out = env.output();
720            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
721        }
722        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
723        assert_eq!(v["total_conversations"].as_u64().unwrap(), 2);
724        assert_eq!(v["filtered"].as_u64().unwrap(), 1);
725    }
726
727    // PR-9i — buffer coverage uplift. Targets the actual mine() write path
728    // (lines 261-356) and invalid format/tier error paths.
729
730    #[test]
731    fn pr9i_mine_actual_write_path_text() {
732        let mut env = TestEnv::fresh();
733        let db = env.db_path.clone();
734        let cfg = config::AppConfig::default();
735        let tmp = tempfile::tempdir().unwrap();
736        let claude_path = write_minimal_claude_export(tmp.path());
737        let args = MineArgs {
738            path: claude_path,
739            format: "claude".to_string(),
740            namespace: Some("mined-real".to_string()),
741            tier: Tier::Long.as_str().to_string(),
742            min_messages: 3,
743            dry_run: false,
744        };
745        {
746            let mut out = env.output();
747            mine(&db, args, false, &cfg, Some("miner-id"), &mut out).unwrap();
748        }
749        let s = env.stdout_str();
750        assert!(s.contains("Imported"));
751        assert!(s.contains("mined-real"));
752        // The conversation with >=3 messages was actually written.
753        let conn = db::open(&db).unwrap();
754        let all = db::export_all(&conn).unwrap();
755        let in_ns: Vec<&_> = all.iter().filter(|m| m.namespace == "mined-real").collect();
756        assert_eq!(
757            in_ns.len(),
758            1,
759            "expected exactly one mined memory in mined-real ns: {all:?}"
760        );
761        // agent_id is the miner; mined_from is the source format.
762        assert_eq!(
763            in_ns[0].metadata.get("agent_id").and_then(|v| v.as_str()),
764            Some("miner-id")
765        );
766        assert_eq!(
767            in_ns[0].metadata.get("mined_from").and_then(|v| v.as_str()),
768            Some("mine-claude")
769        );
770    }
771
772    #[test]
773    fn pr9i_mine_actual_write_path_json() {
774        let mut env = TestEnv::fresh();
775        let db = env.db_path.clone();
776        let cfg = config::AppConfig::default();
777        let tmp = tempfile::tempdir().unwrap();
778        let claude_path = write_minimal_claude_export(tmp.path());
779        let args = MineArgs {
780            path: claude_path,
781            format: "claude".to_string(),
782            namespace: Some("mined-json".to_string()),
783            tier: Tier::Mid.as_str().to_string(),
784            min_messages: 3,
785            dry_run: false,
786        };
787        {
788            let mut out = env.output();
789            mine(&db, args, true, &cfg, Some("miner-x"), &mut out).unwrap();
790        }
791        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
792        assert_eq!(v["namespace"].as_str().unwrap(), "mined-json");
793        assert_eq!(v["tier"].as_str().unwrap(), Tier::Mid.as_str());
794        assert!(v["imported"].as_u64().unwrap() >= 1);
795    }
796
797    #[test]
798    fn pr9i_mine_default_namespace_per_format() {
799        // Omit --namespace; defaults to "<format>-export".
800        let mut env = TestEnv::fresh();
801        let db = env.db_path.clone();
802        let cfg = config::AppConfig::default();
803        let tmp = tempfile::tempdir().unwrap();
804        let claude_path = write_minimal_claude_export(tmp.path());
805        let args = MineArgs {
806            path: claude_path,
807            format: "claude".to_string(),
808            namespace: None,
809            tier: Tier::Mid.as_str().to_string(),
810            min_messages: 3,
811            dry_run: true,
812        };
813        {
814            let mut out = env.output();
815            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
816        }
817        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
818        assert_eq!(v["namespace"].as_str().unwrap(), "claude-export");
819    }
820
821    #[test]
822    fn pr9i_mine_invalid_format_errors() {
823        let mut env = TestEnv::fresh();
824        let db = env.db_path.clone();
825        let cfg = config::AppConfig::default();
826        let tmp = tempfile::tempdir().unwrap();
827        let p = tmp.path().join("anything.jsonl");
828        std::fs::write(&p, "{}").unwrap();
829        let args = MineArgs {
830            path: p,
831            format: "myspace".to_string(), // not claude/chatgpt/slack
832            namespace: None,
833            tier: Tier::Mid.as_str().to_string(),
834            min_messages: 3,
835            dry_run: true,
836        };
837        let mut out = env.output();
838        let res = mine(&db, args, false, &cfg, Some("miner"), &mut out);
839        assert!(res.is_err());
840        assert!(res.unwrap_err().to_string().contains("invalid format"));
841    }
842
843    #[test]
844    fn pr9i_mine_invalid_tier_errors() {
845        let mut env = TestEnv::fresh();
846        let db = env.db_path.clone();
847        let cfg = config::AppConfig::default();
848        let tmp = tempfile::tempdir().unwrap();
849        let p = tmp.path().join("c.jsonl");
850        std::fs::write(&p, "{}").unwrap();
851        let args = MineArgs {
852            path: p,
853            format: "claude".to_string(),
854            namespace: None,
855            tier: "permanent".to_string(), // not short/mid/long
856            min_messages: 3,
857            dry_run: true,
858        };
859        let mut out = env.output();
860        let res = mine(&db, args, false, &cfg, Some("miner"), &mut out);
861        assert!(res.is_err());
862        assert!(res.unwrap_err().to_string().contains("invalid tier"));
863    }
864
865    // ----------------------------------------------------------------
866    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
867    // ----------------------------------------------------------------
868
869    #[test]
870    fn test_import_default_restamps_with_warning_on_text_mode() {
871        // Text-mode (json_out=false) prints the restamp-count line and,
872        // when --trust-source is set, the warning to stderr. Covers
873        // lines 153-167.
874        let mut env = TestEnv::fresh();
875        let db = env.db_path.clone();
876        let payload = serde_json::json!({
877            "memories": [
878                {
879                    "id": "33333333-3333-3333-3333-333333333333",
880                    "tier": Tier::Mid.as_str(),
881                    "namespace": "ns",
882                    "title": "tt",
883                    "content": "cc",
884                    "tags": [],
885                    "priority": 5,
886                    "confidence": 1.0,
887                    "source": "import",
888                    "access_count": 0,
889                    "created_at": "2026-01-01T00:00:00+00:00",
890                    "updated_at": "2026-01-01T00:00:00+00:00",
891                    "last_accessed_at": null,
892                    "expires_at": null,
893                    "metadata": {"agent_id": "other-agent"}
894                }
895            ],
896            "links": [],
897            "count": 1,
898            "exported_at": "2026-01-01T00:00:00+00:00"
899        })
900        .to_string();
901        let args = ImportArgs { trust_source: true };
902        {
903            let mut out = env.output();
904            import_from_str(&payload, &db, &args, false, Some("caller"), &mut out).unwrap();
905        }
906        // Text mode emits "imported: N (restamped agent_id on M)".
907        assert!(env.stdout_str().contains("imported:"));
908        assert!(env.stderr_str().contains("trust-source"));
909    }
910
911    #[test]
912    fn test_import_text_mode_with_errors_lists_them_on_stderr() {
913        // Text-mode failure path that surfaces validation errors on
914        // stderr (lines 163-167).
915        let mut env = TestEnv::fresh();
916        let db = env.db_path.clone();
917        let payload = serde_json::json!({
918            "memories": [
919                {
920                    "id": "44444444-4444-4444-4444-444444444444",
921                    "tier": Tier::Mid.as_str(),
922                    "namespace": "ns",
923                    "title": "",  // empty title fails validate
924                    "content": "c",
925                    "tags": [],
926                    "priority": 5,
927                    "confidence": 1.0,
928                    "source": "import",
929                    "access_count": 0,
930                    "created_at": "2026-01-01T00:00:00+00:00",
931                    "updated_at": "2026-01-01T00:00:00+00:00",
932                    "last_accessed_at": null,
933                    "expires_at": null,
934                    "metadata": {"agent_id": "x"}
935                }
936            ],
937            "links": [],
938            "count": 1,
939            "exported_at": "2026-01-01T00:00:00+00:00"
940        })
941        .to_string();
942        let args = ImportArgs { trust_source: true };
943        {
944            let mut out = env.output();
945            import_from_str(&payload, &db, &args, false, Some("caller"), &mut out).unwrap();
946        }
947        // Errors surface on stderr in text mode.
948        assert!(env.stdout_str().contains("imported: 0"));
949        let stderr = env.stderr_str();
950        assert!(!stderr.is_empty(), "expected at least one error on stderr");
951    }
952
953    #[test]
954    fn test_import_with_valid_link_inserts() {
955        // Drives the link-validate-then-insert branch (lines 128-139)
956        // with a syntactically-valid link.
957        let mut env = TestEnv::fresh();
958        let db = env.db_path.clone();
959        // Seed two valid memories so the link target exists.
960        let id1 = seed_memory(&db, "ns", "a", "ca");
961        let id2 = seed_memory(&db, "ns", "b", "cb");
962        let payload = serde_json::json!({
963            "memories": [],
964            "links": [
965                {
966                    "source_id": id1,
967                    "target_id": id2,
968                    "relation": "related_to",
969                    "created_at": "2026-01-01T00:00:00+00:00"
970                }
971            ],
972            "count": 0,
973            "exported_at": "2026-01-01T00:00:00+00:00"
974        })
975        .to_string();
976        let args = ImportArgs { trust_source: true };
977        {
978            let mut out = env.output();
979            import_from_str(&payload, &db, &args, true, Some("caller"), &mut out).unwrap();
980        }
981        let conn = db::open(&db).unwrap();
982        let links = db::export_links(&conn).unwrap();
983        assert_eq!(links.len(), 1);
984    }
985
986    #[test]
987    fn test_import_default_restamp_preserves_imported_from() {
988        // Non-trust-source mode restamps with caller_id and preserves
989        // `imported_from_agent_id`. Drives lines 96-117 with the
990        // metadata-rewrite path.
991        let mut env = TestEnv::fresh();
992        let db = env.db_path.clone();
993        let payload = serde_json::json!({
994            "memories": [
995                {
996                    "id": "55555555-5555-5555-5555-555555555555",
997                    "tier": Tier::Mid.as_str(),
998                    "namespace": "ns",
999                    "title": "tt",
1000                    "content": "cc",
1001                    "tags": [],
1002                    "priority": 5,
1003                    "confidence": 1.0,
1004                    "source": "import",
1005                    "access_count": 0,
1006                    "created_at": "2026-01-01T00:00:00+00:00",
1007                    "updated_at": "2026-01-01T00:00:00+00:00",
1008                    "last_accessed_at": null,
1009                    "expires_at": null,
1010                    "metadata": {"agent_id": "external"}
1011                }
1012            ],
1013            "links": [],
1014            "count": 1,
1015            "exported_at": "2026-01-01T00:00:00+00:00"
1016        })
1017        .to_string();
1018        let args = ImportArgs {
1019            trust_source: false,
1020        };
1021        {
1022            let mut out = env.output();
1023            import_from_str(&payload, &db, &args, true, Some("caller"), &mut out).unwrap();
1024        }
1025        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1026        assert_eq!(v["imported"].as_u64().unwrap(), 1);
1027        assert_eq!(v["restamped"].as_u64().unwrap(), 1);
1028    }
1029
1030    #[test]
1031    fn test_import_default_restamp_same_caller_id_no_imported_from() {
1032        // When caller id matches existing agent_id, no
1033        // `imported_from_agent_id` is added; restamped count stays 0.
1034        let mut env = TestEnv::fresh();
1035        let db = env.db_path.clone();
1036        let payload = serde_json::json!({
1037            "memories": [
1038                {
1039                    "id": "66666666-6666-6666-6666-666666666666",
1040                    "tier": Tier::Mid.as_str(),
1041                    "namespace": "ns",
1042                    "title": "tt",
1043                    "content": "cc",
1044                    "tags": [],
1045                    "priority": 5,
1046                    "confidence": 1.0,
1047                    "source": "import",
1048                    "access_count": 0,
1049                    "created_at": "2026-01-01T00:00:00+00:00",
1050                    "updated_at": "2026-01-01T00:00:00+00:00",
1051                    "last_accessed_at": null,
1052                    "expires_at": null,
1053                    "metadata": {"agent_id": "same-caller"}
1054                }
1055            ],
1056            "links": [],
1057            "count": 1,
1058            "exported_at": "2026-01-01T00:00:00+00:00"
1059        })
1060        .to_string();
1061        let args = ImportArgs {
1062            trust_source: false,
1063        };
1064        {
1065            let mut out = env.output();
1066            import_from_str(&payload, &db, &args, true, Some("same-caller"), &mut out).unwrap();
1067        }
1068        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1069        assert_eq!(v["imported"].as_u64().unwrap(), 1);
1070        // No restamp metadata insertion fires because caller_id matches.
1071        assert_eq!(v["restamped"].as_u64().unwrap(), 0);
1072    }
1073
1074    fn write_minimal_chatgpt_export(path: &Path) {
1075        let arr = serde_json::json!([
1076            {
1077                "id": "chat-1",
1078                "title": "ChatGPT Conv",
1079                "create_time": 1_700_000_000_i64,
1080                "mapping": {
1081                    "n1": {
1082                        "message": {
1083                            "author": { "role": "user" },
1084                            "create_time": 1.0,
1085                            "content": { "parts": ["hello"] }
1086                        }
1087                    },
1088                    "n2": {
1089                        "message": {
1090                            "author": { "role": "assistant" },
1091                            "create_time": 2.0,
1092                            "content": { "parts": ["hi back"] }
1093                        }
1094                    },
1095                    "n3": {
1096                        "message": {
1097                            "author": { "role": "user" },
1098                            "create_time": 3.0,
1099                            "content": { "parts": ["question"] }
1100                        }
1101                    }
1102                }
1103            }
1104        ]);
1105        std::fs::write(path, serde_json::to_string(&arr).unwrap()).unwrap();
1106    }
1107
1108    #[test]
1109    fn pr9i_mine_chatgpt_actual_write_path() {
1110        // Drives the `mine::Format::ChatGpt` parse branch (line 201) and
1111        // the actual-write path through to db::insert.
1112        let mut env = TestEnv::fresh();
1113        let db = env.db_path.clone();
1114        let cfg = config::AppConfig::default();
1115        let tmp = tempfile::tempdir().unwrap();
1116        let path = tmp.path().join("chatgpt.json");
1117        write_minimal_chatgpt_export(&path);
1118        let args = MineArgs {
1119            path,
1120            format: "chatgpt".to_string(),
1121            namespace: None,
1122            tier: Tier::Short.as_str().to_string(),
1123            min_messages: 3,
1124            dry_run: false,
1125        };
1126        {
1127            let mut out = env.output();
1128            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
1129        }
1130        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1131        assert_eq!(v["namespace"].as_str().unwrap(), "chatgpt-export");
1132    }
1133
1134    #[test]
1135    fn pr9i_mine_slack_dry_run_via_directory() {
1136        // Drives the `mine::Format::Slack` parse branch (line 202).
1137        // Slack expects a directory tree — even an empty channel
1138        // dir exercises the parser.
1139        let mut env = TestEnv::fresh();
1140        let db = env.db_path.clone();
1141        let cfg = config::AppConfig::default();
1142        let tmp = tempfile::tempdir().unwrap();
1143        // Make an empty channel subdirectory so parse_slack walks it.
1144        std::fs::create_dir(tmp.path().join("general")).unwrap();
1145        let args = MineArgs {
1146            path: tmp.path().to_path_buf(),
1147            format: "slack".to_string(),
1148            namespace: None,
1149            tier: Tier::Mid.as_str().to_string(),
1150            min_messages: 3,
1151            dry_run: true,
1152        };
1153        {
1154            let mut out = env.output();
1155            // We don't care about success/failure here — `parse_slack`
1156            // may or may not return Ok depending on the channel dir
1157            // shape. We're after the format-dispatch branch coverage.
1158            let _ = mine(&db, args, true, &cfg, Some("miner"), &mut out);
1159        }
1160    }
1161
1162    #[test]
1163    fn pr9i_mine_chatgpt_default_namespace() {
1164        // Default namespace for chatgpt is "chatgpt-export".
1165        let mut env = TestEnv::fresh();
1166        let db = env.db_path.clone();
1167        let cfg = config::AppConfig::default();
1168        let tmp = tempfile::tempdir().unwrap();
1169        let path = tmp.path().join("c.json");
1170        write_minimal_chatgpt_export(&path);
1171        let args = MineArgs {
1172            path,
1173            format: "chatgpt".to_string(),
1174            namespace: None,
1175            tier: Tier::Mid.as_str().to_string(),
1176            min_messages: 1,
1177            dry_run: true,
1178        };
1179        {
1180            let mut out = env.output();
1181            mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
1182        }
1183        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1184        assert_eq!(v["namespace"].as_str().unwrap(), "chatgpt-export");
1185    }
1186
1187    #[test]
1188    fn pr9i_mine_text_actual_write_path_text_output() {
1189        // Drives the text-mode "Imported …" emission (lines 353-361).
1190        let mut env = TestEnv::fresh();
1191        let db = env.db_path.clone();
1192        let cfg = config::AppConfig::default();
1193        let tmp = tempfile::tempdir().unwrap();
1194        let claude_path = write_minimal_claude_export(tmp.path());
1195        let args = MineArgs {
1196            path: claude_path,
1197            format: "claude".to_string(),
1198            namespace: Some("text-mine".to_string()),
1199            tier: Tier::Long.as_str().to_string(),
1200            min_messages: 3,
1201            dry_run: false,
1202        };
1203        {
1204            let mut out = env.output();
1205            mine(&db, args, false, &cfg, Some("miner"), &mut out).unwrap();
1206        }
1207        let stdout = env.stdout_str();
1208        assert!(stdout.contains("Imported"));
1209        assert!(stdout.contains("Namespace: text-mine"));
1210    }
1211
1212    #[test]
1213    fn pr9i_mine_text_dry_run_lists_filtered_titles() {
1214        // Text-mode dry_run prints conversation titles (lines 232-256).
1215        let mut env = TestEnv::fresh();
1216        let db = env.db_path.clone();
1217        let cfg = config::AppConfig::default();
1218        let tmp = tempfile::tempdir().unwrap();
1219        let claude_path = write_minimal_claude_export(tmp.path());
1220        let args = MineArgs {
1221            path: claude_path,
1222            format: "claude".to_string(),
1223            namespace: Some("dry-text".to_string()),
1224            tier: Tier::Short.as_str().to_string(),
1225            min_messages: 3,
1226            dry_run: true,
1227        };
1228        {
1229            let mut out = env.output();
1230            mine(&db, args, false, &cfg, Some("miner"), &mut out).unwrap();
1231        }
1232        let s = env.stdout_str();
1233        assert!(s.contains("Dry run"));
1234        assert!(s.contains("Total conversations found"));
1235        assert!(s.contains("After filter"));
1236        assert!(s.contains("dry-text"));
1237        // The Claude conversation with name "Conv with 5 messages" must be
1238        // listed in the filtered preview.
1239        assert!(
1240            s.contains("5 msgs") || s.contains("Conv with 5 messages"),
1241            "expected conversation listing in dry-run text output: {s}"
1242        );
1243    }
1244}