1use 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 #[arg(long, default_value_t = false)]
20 pub trust_source: bool,
21}
22
23#[derive(Args)]
24pub struct MineArgs {
25 pub path: PathBuf,
27 #[arg(long, short)]
29 pub format: String,
30 #[arg(long, short)]
32 pub namespace: Option<String>,
33 #[arg(long, short, default_value = "mid")]
41 pub tier: String,
42 #[arg(long, default_value_t = 3)]
44 pub min_messages: usize,
45 #[arg(long, default_value_t = false)]
47 pub dry_run: bool,
48}
49
50pub 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
66pub 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
81pub(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#[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 #[test]
392 fn test_export_empty_db() {
393 let mut env = TestEnv::fresh();
394 let db = env.db_path.clone();
395 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 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 let s = env.stdout_str();
441 assert!(s.contains('\n'));
442 assert!(s.contains(" \"memories\""));
443 }
444
445 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 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 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": "", "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 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 fn write_minimal_claude_export(dir: &Path) -> PathBuf {
643 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 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 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 #[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 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 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 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(), 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(), 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 #[test]
870 fn test_import_default_restamps_with_warning_on_text_mode() {
871 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 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 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": "", "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 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 let mut env = TestEnv::fresh();
958 let db = env.db_path.clone();
959 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 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 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 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 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 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 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 let _ = mine(&db, args, true, &cfg, Some("miner"), &mut out);
1159 }
1160 }
1161
1162 #[test]
1163 fn pr9i_mine_chatgpt_default_namespace() {
1164 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 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 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 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}