1use std::path::{Path, PathBuf};
49use std::time::{SystemTime, UNIX_EPOCH};
50
51use anyhow::Result;
52use slugify::slugify;
53
54use crate::store::record::{
55 Category, ConfidenceScore, DeviceId, Priority, QualityScore, Record, RecordLifecycle,
56 RecordSource, RecordVersion, StalenessScore,
57};
58
59pub struct AutoMemoryImport {
61 pub records: Vec<Record>,
64 pub skipped_files: Vec<(PathBuf, String)>,
67}
68
69pub fn auto_memory_dir(project_root: &Path) -> Result<PathBuf> {
78 let slug = project_root.to_string_lossy().replace('/', "-");
79 let home = dirs::home_dir()
80 .ok_or_else(|| anyhow::anyhow!("cannot determine home directory (HOME not set)"))?;
81 Ok(home
82 .join(".claude")
83 .join("projects")
84 .join(slug)
85 .join("memory"))
86}
87
88pub fn import_auto_memory(
94 dir: &Path,
95 device_id: DeviceId,
96 logical_clock_start: u64,
97) -> Result<AutoMemoryImport> {
98 let entries = match std::fs::read_dir(dir) {
99 Ok(e) => e,
100 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
101 return Ok(AutoMemoryImport {
102 records: vec![],
103 skipped_files: vec![],
104 });
105 }
106 Err(e) => return Err(e.into()),
107 };
108
109 let mut paths: Vec<PathBuf> = entries
110 .filter_map(|e| e.ok())
111 .map(|e| e.path())
112 .filter(|p| {
113 p.extension().and_then(|e| e.to_str()) == Some("md")
114 && p.file_name().and_then(|n| n.to_str()) != Some("MEMORY.md")
115 })
116 .collect();
117 paths.sort();
120
121 let now = SystemTime::now()
122 .duration_since(UNIX_EPOCH)
123 .unwrap_or_default()
124 .as_secs();
125
126 let mut records = Vec::with_capacity(paths.len());
127 let mut skipped_files = Vec::new();
128 let mut clock = logical_clock_start;
129
130 for path in &paths {
131 let content = match std::fs::read_to_string(path) {
132 Ok(c) => c,
133 Err(e) => {
134 skipped_files.push((path.clone(), e.to_string()));
135 continue;
136 }
137 };
138 match parse_memory_file(&content) {
139 Some(parsed) => {
140 records.push(memory_to_record(&parsed, path, device_id, clock, now));
141 clock += 1;
142 }
143 None => skipped_files.push((
144 path.clone(),
145 "no frontmatter description and no body content".to_string(),
146 )),
147 }
148 }
149
150 Ok(AutoMemoryImport {
151 records,
152 skipped_files,
153 })
154}
155
156struct ParsedMemory {
159 name: Option<String>,
160 description: Option<String>,
161 memo_type: Option<String>,
162 body: String,
163}
164
165fn parse_memory_file(content: &str) -> Option<ParsedMemory> {
172 let (frontmatter, body) = split_frontmatter(content);
173 let body = body.trim().to_string();
174
175 let mut name = None;
176 let mut description = None;
177 let mut memo_type = None;
178
179 if let Some(fm) = frontmatter {
180 for line in fm.lines() {
181 if line.starts_with(char::is_whitespace) {
182 if let Some((key, val)) = split_yaml_kv(line.trim_start()) {
187 if key == "type" {
188 memo_type = Some(val);
189 }
190 }
191 continue;
192 }
193 if let Some((key, val)) = split_yaml_kv(line) {
194 match key.as_str() {
195 "name" => name = Some(val),
196 "description" => description = Some(val),
197 _ => {} }
199 }
200 }
201 }
202
203 if description.as_deref().unwrap_or("").trim().is_empty() && body.is_empty() {
204 return None;
205 }
206
207 Some(ParsedMemory {
208 name,
209 description,
210 memo_type,
211 body,
212 })
213}
214
215fn split_frontmatter(content: &str) -> (Option<String>, String) {
221 let mut lines = content.lines();
222 match lines.next() {
223 Some("---") => {}
224 _ => return (None, content.to_string()),
225 }
226
227 let mut fm_lines = Vec::new();
228 let mut closed = false;
229 for line in lines.by_ref() {
230 if line == "---" {
231 closed = true;
232 break;
233 }
234 fm_lines.push(line);
235 }
236
237 if !closed {
238 return (None, content.to_string());
239 }
240
241 let body: Vec<&str> = lines.collect();
242 (Some(fm_lines.join("\n")), body.join("\n"))
243}
244
245fn split_yaml_kv(line: &str) -> Option<(String, String)> {
249 let (key, rest) = line.split_once(':')?;
250 let key = key.trim();
251 if key.is_empty() {
252 return None;
253 }
254 let val = rest.trim();
255 if val.is_empty() {
256 return None;
257 }
258 Some((key.to_string(), unquote_yaml_value(val)))
259}
260
261fn unquote_yaml_value(val: &str) -> String {
268 if val.len() >= 2 && val.starts_with('"') && val.ends_with('"') {
269 val[1..val.len() - 1].replace("\\\"", "\"")
270 } else {
271 val.to_string()
272 }
273}
274
275fn memory_to_record(
278 parsed: &ParsedMemory,
279 path: &Path,
280 device_id: DeviceId,
281 logical_clock: u64,
282 now: u64,
283) -> Record {
284 let file_stem = path
285 .file_stem()
286 .and_then(|s| s.to_str())
287 .unwrap_or("memory");
288 let base_name = parsed
289 .name
290 .clone()
291 .filter(|n| !n.trim().is_empty())
292 .unwrap_or_else(|| file_stem.to_string());
293 let slug = slugify!(&base_name, max_length = 60);
294 let key = format!("dev_note:auto-memory-{slug}");
295
296 let (rule, reason) = rule_and_reason(parsed);
297
298 let value = if reason.is_empty() {
299 rule
300 } else {
301 format!("{rule} because {reason}")
302 };
303
304 let mut tags = vec!["source:auto-memory".to_string()];
305 if let Some(t) = parsed.memo_type.as_deref().filter(|t| !t.is_empty()) {
306 tags.push(format!("auto-memory:{t}"));
307 }
308
309 let mut record = Record {
310 key,
311 value,
312 category: Category::DevNote,
313 priority: Priority::Normal,
314 tags,
315 created_at: now,
316 updated_at: now,
317 ref_url: None,
318 staleness: StalenessScore::fresh(),
319 lifecycle: RecordLifecycle::Active,
320 version: RecordVersion {
321 device_id,
322 logical_clock,
323 wall_clock: now,
324 },
325 quality: QualityScore::layer0_default(),
326 access_count: 0,
327 last_accessed: 0,
328 source: RecordSource::Import,
329 confidence: ConfidenceScore::for_new_record(&RecordSource::Import),
330 gap_analysis_score: 0.0,
331 payload: None,
332 };
333 record.quality = crate::health::quality::analyze(&record);
334 record
335}
336
337fn rule_and_reason(parsed: &ParsedMemory) -> (String, String) {
341 if let Some(d) = parsed.description.as_ref().filter(|d| !d.trim().is_empty()) {
342 return (d.clone(), parsed.body.clone());
343 }
344
345 let mut lines = parsed.body.lines();
346 let first = lines
347 .find(|l| !l.trim().is_empty())
348 .unwrap_or("")
349 .trim()
350 .to_string();
351 let rest = lines.collect::<Vec<_>>().join("\n").trim().to_string();
352
353 if first.is_empty() {
354 ("untitled auto-memory note".to_string(), rest)
355 } else {
356 (first, rest)
357 }
358}
359
360#[cfg(test)]
363mod tests {
364 use super::*;
365
366 const SAMPLE: &str = "\
367---
368name: project-ci-unavailable
369description: \"GitHub Actions minutes are exhausted and won't be renewed — local gates are the only validation\"
370metadata:
371 node_type: memory
372 type: project
373 originSessionId: 1a0cfc6e-b7d8-458a-b26b-fb6c9f69100f
374 modified: 2026-08-07T01:13:09.131Z
375---
376
377As of 2026-08-04 the mati repo's GitHub Actions quota is exhausted.
378
379**Why:** deliberate cost decision.
380";
381
382 #[test]
385 fn split_frontmatter_extracts_both_parts() {
386 let (fm, body) = split_frontmatter(SAMPLE);
387 let fm = fm.expect("frontmatter present");
388 assert!(fm.contains("name: project-ci-unavailable"));
389 assert!(fm.contains(" type: project"));
390 assert!(body.trim_start().starts_with("As of 2026-08-04"));
391 assert!(body.contains("**Why:**"));
392 }
393
394 #[test]
395 fn split_frontmatter_missing_delimiter_returns_whole_file_as_body() {
396 let content = "Just a plain note, no frontmatter.\nSecond line.";
397 let (fm, body) = split_frontmatter(content);
398 assert!(fm.is_none());
399 assert_eq!(body, content);
400 }
401
402 #[test]
403 fn split_frontmatter_unclosed_block_returns_whole_file_as_body() {
404 let content = "---\nname: broken\nno closing delimiter here";
405 let (fm, body) = split_frontmatter(content);
406 assert!(fm.is_none());
407 assert_eq!(body, content);
408 }
409
410 #[test]
413 fn split_yaml_kv_unquoted() {
414 let (k, v) = split_yaml_kv("name: project-ci-unavailable").unwrap();
415 assert_eq!(k, "name");
416 assert_eq!(v, "project-ci-unavailable");
417 }
418
419 #[test]
420 fn split_yaml_kv_quoted_with_embedded_colon() {
421 let (k, v) = split_yaml_kv(
422 "description: \"Store/daemon single-owner invariant: defect found 2026-07-23\"",
423 )
424 .unwrap();
425 assert_eq!(k, "description");
426 assert_eq!(
427 v,
428 "Store/daemon single-owner invariant: defect found 2026-07-23"
429 );
430 }
431
432 #[test]
433 fn split_yaml_kv_quoted_with_escaped_quotes() {
434 let (k, v) =
435 split_yaml_kv("description: \"do not \\\"fix\\\" them without asking\"").unwrap();
436 assert_eq!(k, "description");
437 assert_eq!(v, "do not \"fix\" them without asking");
438 }
439
440 #[test]
441 fn split_yaml_kv_nested_block_opener_has_no_value() {
442 assert!(split_yaml_kv("metadata: ").is_none());
443 assert!(split_yaml_kv("metadata:").is_none());
444 }
445
446 #[test]
449 fn parse_memory_file_reads_name_description_type_and_body() {
450 let parsed = parse_memory_file(SAMPLE).expect("sample parses");
451 assert_eq!(parsed.name.as_deref(), Some("project-ci-unavailable"));
452 assert_eq!(
453 parsed.description.as_deref(),
454 Some("GitHub Actions minutes are exhausted and won't be renewed — local gates are the only validation")
455 );
456 assert_eq!(parsed.memo_type.as_deref(), Some("project"));
457 assert!(parsed.body.contains("**Why:**"));
458 }
459
460 #[test]
461 fn parse_memory_file_no_frontmatter_still_uses_body() {
462 let parsed = parse_memory_file("Just a plain note with real content.")
463 .expect("body-only content still parses");
464 assert!(parsed.name.is_none());
465 assert!(parsed.description.is_none());
466 assert_eq!(parsed.body, "Just a plain note with real content.");
467 }
468
469 #[test]
470 fn parse_memory_file_empty_everything_is_none() {
471 let content = "---\nname: empty\n---\n\n";
472 assert!(parse_memory_file(content).is_none());
473 }
474
475 #[test]
476 fn parse_memory_file_unknown_frontmatter_keys_are_ignored() {
477 let content = "\
478---
479name: has-extra-field
480description: \"a real description\"
481future_field: something new upstream added
482---
483
484body text here
485";
486 let parsed = parse_memory_file(content).expect("unknown keys don't reject the file");
487 assert_eq!(parsed.description.as_deref(), Some("a real description"));
488 }
489
490 #[test]
493 fn rule_and_reason_prefers_description() {
494 let parsed = ParsedMemory {
495 name: None,
496 description: Some("Do the thing.".to_string()),
497 memo_type: None,
498 body: "Full body text.".to_string(),
499 };
500 let (rule, reason) = rule_and_reason(&parsed);
501 assert_eq!(rule, "Do the thing.");
502 assert_eq!(reason, "Full body text.");
503 }
504
505 #[test]
506 fn rule_and_reason_falls_back_to_first_body_line() {
507 let parsed = ParsedMemory {
508 name: None,
509 description: None,
510 memo_type: None,
511 body: "First line is the rule.\nRest is reason.\nMore reason.".to_string(),
512 };
513 let (rule, reason) = rule_and_reason(&parsed);
514 assert_eq!(rule, "First line is the rule.");
515 assert_eq!(reason, "Rest is reason.\nMore reason.");
516 }
517
518 #[test]
521 fn memory_to_record_is_a_dev_note_and_tagged() {
522 let parsed = parse_memory_file(SAMPLE).unwrap();
523 let record = memory_to_record(
524 &parsed,
525 Path::new("/home/x/.claude/projects/foo/memory/project_ci_unavailable.md"),
526 uuid::Uuid::nil(),
527 1,
528 1000,
529 );
530 assert_eq!(record.category, Category::DevNote);
531 assert!(record.key.starts_with("dev_note:auto-memory-"));
532 assert!(record.tags.contains(&"source:auto-memory".to_string()));
533 assert!(record.tags.contains(&"auto-memory:project".to_string()));
534 assert!(
535 record.payload.is_none(),
536 "dev notes carry plain text in `value`, no structured payload"
537 );
538 assert!(record.value.contains("GitHub Actions"));
539 }
540
541 #[test]
542 fn import_auto_memory_missing_dir_returns_empty_not_error() {
543 let result = import_auto_memory(Path::new("/nonexistent/memory/dir"), uuid::Uuid::nil(), 0);
544 let import = result.unwrap();
545 assert!(import.records.is_empty());
546 assert!(import.skipped_files.is_empty());
547 }
548
549 #[test]
550 fn import_auto_memory_skips_index_and_malformed_but_keeps_going() {
551 let dir = tempfile::TempDir::new().unwrap();
552 std::fs::write(dir.path().join("MEMORY.md"), "- [Title](x.md) — desc\n").unwrap();
553 std::fs::write(dir.path().join("good.md"), SAMPLE).unwrap();
554 std::fs::write(dir.path().join("empty.md"), "---\nname: empty\n---\n\n").unwrap();
555
556 let import = import_auto_memory(dir.path(), uuid::Uuid::nil(), 0).unwrap();
557 assert_eq!(
558 import.records.len(),
559 1,
560 "MEMORY.md excluded, empty.md skipped"
561 );
562 assert_eq!(import.skipped_files.len(), 1);
563 assert_eq!(import.skipped_files[0].0.file_name().unwrap(), "empty.md");
564 }
565
566 #[test]
567 fn import_auto_memory_unreadable_file_is_skipped_not_fatal() {
568 let dir = tempfile::TempDir::new().unwrap();
569 std::fs::write(dir.path().join("good.md"), SAMPLE).unwrap();
570 std::fs::create_dir(dir.path().join("bad.md")).unwrap();
573
574 let import = import_auto_memory(dir.path(), uuid::Uuid::nil(), 0).unwrap();
575 assert_eq!(import.records.len(), 1);
576 assert_eq!(import.skipped_files.len(), 1);
577 }
578
579 #[test]
580 fn auto_memory_dir_replaces_slashes_with_dashes() {
581 let home = dirs::home_dir().unwrap();
584 let dir = auto_memory_dir(Path::new(
585 "/Users/ioni/Documents/Tools-projects/mati-projects/mati",
586 ))
587 .unwrap();
588 assert_eq!(
589 dir,
590 home.join(".claude")
591 .join("projects")
592 .join("-Users-ioni-Documents-Tools-projects-mati-projects-mati")
593 .join("memory")
594 );
595 }
596}