1use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::config::ZigConfig;
23use crate::error::ZigError;
24use crate::paths;
25use crate::workflow::model::MemoryMode;
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct Manifest {
34 pub next_id: u64,
36 pub entries: BTreeMap<String, MemoryEntry>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct MemoryEntry {
43 pub name: String,
45 pub file: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub description: Option<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub tags: Vec<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub step: Option<String>,
56 pub source: String,
58 pub added: DateTime<Utc>,
60}
61
62#[derive(Debug, Clone)]
64pub enum MemoryTarget {
65 GlobalShared,
67 GlobalWorkflow(String),
69 Cwd,
71}
72
73impl MemoryTarget {
74 pub fn from_flags(workflow: Option<&str>, global: bool, cwd: bool) -> Result<Self, ZigError> {
76 if let Some(name) = workflow {
77 if cwd {
78 return Err(ZigError::Validation(
79 "--workflow cannot be combined with --cwd".into(),
80 ));
81 }
82 return Ok(MemoryTarget::GlobalWorkflow(name.to_string()));
83 }
84 if cwd {
85 return Ok(MemoryTarget::Cwd);
86 }
87 if global {
88 return Ok(MemoryTarget::GlobalShared);
89 }
90 Ok(MemoryTarget::Cwd)
92 }
93
94 pub fn ensure_dir(&self) -> Result<PathBuf, ZigError> {
96 match self {
97 MemoryTarget::GlobalShared => paths::ensure_global_memory_dir(Some("_shared")),
98 MemoryTarget::GlobalWorkflow(name) => paths::ensure_global_memory_dir(Some(name)),
99 MemoryTarget::Cwd => ensure_cwd_memory_dir(),
100 }
101 }
102
103 pub fn existing_dir(&self) -> Option<PathBuf> {
105 match self {
106 MemoryTarget::GlobalShared => paths::global_shared_memory_dir(),
107 MemoryTarget::GlobalWorkflow(name) => paths::global_memory_for(name),
108 MemoryTarget::Cwd => paths::cwd_memory_dir().or_else(|| {
109 std::env::current_dir()
110 .ok()
111 .map(|p| p.join(".zig").join("memory"))
112 }),
113 }
114 }
115
116 pub fn label(&self) -> String {
118 match self {
119 MemoryTarget::GlobalShared => "global:_shared".to_string(),
120 MemoryTarget::GlobalWorkflow(n) => format!("global:{n}"),
121 MemoryTarget::Cwd => "cwd".to_string(),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum SearchScope {
129 Sentence,
131 Paragraph,
133 Section,
135 File,
137}
138
139fn manifest_path(dir: &Path) -> PathBuf {
144 dir.join(".manifest")
145}
146
147pub fn load_manifest(dir: &Path) -> Result<Manifest, ZigError> {
150 let path = manifest_path(dir);
151 if !path.exists() {
152 return Ok(Manifest {
153 next_id: 1,
154 entries: BTreeMap::new(),
155 });
156 }
157 let content = std::fs::read_to_string(&path)
158 .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", path.display())))?;
159 serde_json::from_str(&content).map_err(|e| {
160 ZigError::Io(format!(
161 "failed to parse manifest at {}: {e}",
162 path.display()
163 ))
164 })
165}
166
167pub fn save_manifest(dir: &Path, manifest: &Manifest) -> Result<(), ZigError> {
169 let path = manifest_path(dir);
170 let content = serde_json::to_string_pretty(manifest)
171 .map_err(|e| ZigError::Serialize(format!("failed to serialize manifest: {e}")))?;
172 std::fs::write(&path, content)
173 .map_err(|e| ZigError::Io(format!("failed to write {}: {e}", path.display())))
174}
175
176fn ensure_cwd_memory_dir() -> Result<PathBuf, ZigError> {
177 if let Some(existing) = paths::cwd_memory_dir() {
178 return Ok(existing);
179 }
180 let cwd = std::env::current_dir()
181 .map_err(|e| ZigError::Io(format!("failed to read current directory: {e}")))?;
182 let dir = cwd.join(".zig").join("memory");
183 std::fs::create_dir_all(&dir)
184 .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
185 Ok(dir)
186}
187
188fn candidate_dirs(workflow: Option<&str>) -> Vec<(String, PathBuf)> {
194 let mut out: Vec<(String, PathBuf)> = Vec::new();
195 if let Some(d) = paths::global_shared_memory_dir() {
196 out.push(("global:_shared".into(), d));
197 }
198 if let Some(name) = workflow {
199 if let Some(d) = paths::global_memory_for(name) {
200 out.push((format!("global:{name}"), d));
201 }
202 }
203 if let Some(d) = paths::cwd_memory_dir() {
204 out.push(("cwd".into(), d));
205 } else if let Ok(cwd) = std::env::current_dir() {
206 out.push(("cwd".into(), cwd.join(".zig").join("memory")));
207 }
208 out
209}
210
211fn find_entry_across_tiers(
214 id: u64,
215 workflow: Option<&str>,
216) -> Result<(PathBuf, String, Manifest, MemoryEntry), ZigError> {
217 let id_str = id.to_string();
218 let dirs = candidate_dirs(workflow);
220 for (label, dir) in dirs.iter().rev() {
221 if !dir.is_dir() {
222 continue;
223 }
224 let manifest = load_manifest(dir)?;
225 if let Some(entry) = manifest.entries.get(&id_str).cloned() {
226 return Ok((dir.clone(), label.clone(), manifest, entry));
227 }
228 }
229 Err(ZigError::Io(format!(
230 "memory entry with id {id} not found in any tier"
231 )))
232}
233
234pub fn add(
243 file_path: &str,
244 target: MemoryTarget,
245 step: Option<&str>,
246 name: Option<&str>,
247 description: Option<&str>,
248 tags: &[String],
249) -> Result<u64, ZigError> {
250 let src = Path::new(file_path);
251 if !src.exists() {
252 return Err(ZigError::Io(format!("source file not found: {file_path}")));
253 }
254 if !src.is_file() {
255 return Err(ZigError::Io(format!("not a regular file: {file_path}")));
256 }
257
258 let dir = target.ensure_dir()?;
259 let mut manifest = load_manifest(&dir)?;
260
261 let id = manifest.next_id;
262 manifest.next_id += 1;
263
264 let file_name = name
265 .map(str::to_string)
266 .or_else(|| src.file_name().map(|n| n.to_string_lossy().into_owned()))
267 .ok_or_else(|| ZigError::Io(format!("could not derive a name from {}", src.display())))?;
268
269 let dest = dir.join(&file_name);
270 if dest.exists() {
271 return Err(ZigError::Io(format!(
272 "file '{}' already exists in {} — remove it first or use --name to rename",
273 file_name,
274 dir.display()
275 )));
276 }
277
278 std::fs::copy(src, &dest).map_err(|e| {
279 ZigError::Io(format!(
280 "failed to copy {} → {}: {e}",
281 src.display(),
282 dest.display()
283 ))
284 })?;
285
286 let source_abs = std::fs::canonicalize(src)
287 .unwrap_or_else(|_| src.to_path_buf())
288 .display()
289 .to_string();
290
291 let entry = MemoryEntry {
292 name: file_name.clone(),
293 file: file_name,
294 description: description.map(str::to_string),
295 tags: tags.to_vec(),
296 step: step.map(str::to_string),
297 source: source_abs,
298 added: Utc::now(),
299 };
300
301 manifest.entries.insert(id.to_string(), entry);
302 save_manifest(&dir, &manifest)?;
303
304 println!(
305 "added memory entry id={id} '{}' to {}",
306 manifest.entries[&id.to_string()].name,
307 target.label()
308 );
309
310 if description.is_none() {
311 eprintln!("hint: add a description with `zig memory update {id} --description \"...\"`");
312 }
313
314 Ok(id)
315}
316
317pub fn update(
319 id: u64,
320 workflow: Option<&str>,
321 name: Option<&str>,
322 description: Option<&str>,
323 tags: Option<&[String]>,
324) -> Result<(), ZigError> {
325 let (dir, label, mut manifest, _entry) = find_entry_across_tiers(id, workflow)?;
326 let id_str = id.to_string();
327
328 let entry = manifest
329 .entries
330 .get_mut(&id_str)
331 .ok_or_else(|| ZigError::Io(format!("memory entry {id} vanished during update")))?;
332
333 if let Some(n) = name {
334 let old_path = dir.join(&entry.file);
336 let new_path = dir.join(n);
337 if old_path != new_path {
338 if new_path.exists() {
339 return Err(ZigError::Io(format!(
340 "cannot rename: '{}' already exists in {}",
341 n,
342 dir.display()
343 )));
344 }
345 std::fs::rename(&old_path, &new_path).map_err(|e| {
346 ZigError::Io(format!(
347 "failed to rename {} → {}: {e}",
348 old_path.display(),
349 new_path.display()
350 ))
351 })?;
352 entry.file = n.to_string();
353 }
354 entry.name = n.to_string();
355 }
356 if let Some(d) = description {
357 entry.description = Some(d.to_string());
358 }
359 if let Some(t) = tags {
360 entry.tags = t.to_vec();
361 }
362
363 save_manifest(&dir, &manifest)?;
364 println!("updated memory entry id={id} in {label}");
365 Ok(())
366}
367
368pub fn delete(id: u64, workflow: Option<&str>) -> Result<(), ZigError> {
370 let (dir, label, mut manifest, entry) = find_entry_across_tiers(id, workflow)?;
371 let id_str = id.to_string();
372
373 let file_path = dir.join(&entry.file);
374 if file_path.is_file() {
375 std::fs::remove_file(&file_path)
376 .map_err(|e| ZigError::Io(format!("failed to remove {}: {e}", file_path.display())))?;
377 }
378
379 manifest.entries.remove(&id_str);
380 save_manifest(&dir, &manifest)?;
381 println!("deleted memory entry id={id} '{}' from {label}", entry.name);
382 Ok(())
383}
384
385pub fn show(id: u64, workflow: Option<&str>) -> Result<(), ZigError> {
387 let (dir, label, _manifest, entry) = find_entry_across_tiers(id, workflow)?;
388 let file_path = dir.join(&entry.file);
389
390 println!("id: {id}");
391 println!("name: {}", entry.name);
392 println!("tier: {label}");
393 println!("source: {}", entry.source);
394 println!(
395 "added: {}",
396 entry.added.format("%Y-%m-%d %H:%M:%S UTC")
397 );
398 if let Some(ref desc) = entry.description {
399 println!("description: {desc}");
400 }
401 if !entry.tags.is_empty() {
402 println!("tags: {}", entry.tags.join(", "));
403 }
404 if let Some(ref step) = entry.step {
405 println!("step: {step}");
406 }
407
408 if file_path.is_file() {
409 let contents = std::fs::read_to_string(&file_path)
410 .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", file_path.display())))?;
411 println!("\n--- contents ({}) ---", file_path.display());
412 print!("{contents}");
413 if !contents.ends_with('\n') {
414 println!();
415 }
416 } else {
417 println!("\n(file not found: {})", file_path.display());
418 }
419
420 Ok(())
421}
422
423pub fn list(workflow: Option<&str>) -> Result<(), ZigError> {
425 let mut rows: Vec<(String, String, String, String, String, String)> = Vec::new();
426
427 let dirs = candidate_dirs(workflow);
428 for (label, dir) in &dirs {
429 if !dir.is_dir() {
430 continue;
431 }
432 let manifest = load_manifest(dir)?;
433 for (id_str, entry) in &manifest.entries {
434 let desc = entry
435 .description
436 .as_deref()
437 .unwrap_or("")
438 .chars()
439 .take(50)
440 .collect::<String>();
441 let tags = entry.tags.join(", ");
442 rows.push((
443 id_str.clone(),
444 entry.name.clone(),
445 tags,
446 desc,
447 label.clone(),
448 entry.step.clone().unwrap_or_default(),
449 ));
450 }
451 }
452
453 if rows.is_empty() {
454 println!("No memory entries found.");
455 println!("Hint: add one with `zig memory add <file> [--workflow <name>]`");
456 return Ok(());
457 }
458
459 let id_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(2).max(2);
460 let name_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4).max(4);
461 let tags_w = rows.iter().map(|r| r.2.len()).max().unwrap_or(4).max(4);
462 let tier_w = rows.iter().map(|r| r.4.len()).max().unwrap_or(4).max(4);
463
464 println!(
465 "{:<id_w$} {:<name_w$} {:<tags_w$} {:<tier_w$} DESCRIPTION",
466 "ID", "NAME", "TAGS", "TIER",
467 );
468 for (id, name, tags, desc, tier, _step) in &rows {
469 println!(
470 "{:<id_w$} {:<name_w$} {:<tags_w$} {:<tier_w$} {desc}",
471 id, name, tags, tier,
472 );
473 }
474
475 Ok(())
476}
477
478pub fn search(query: &str, scope: SearchScope, workflow: Option<&str>) -> Result<(), ZigError> {
484 let query_lower = query.to_lowercase();
485 let mut found = false;
486
487 let dirs = candidate_dirs(workflow);
488 for (label, dir) in &dirs {
489 if !dir.is_dir() {
490 continue;
491 }
492 let manifest = load_manifest(dir)?;
493 for (id_str, entry) in &manifest.entries {
494 let file_path = dir.join(&entry.file);
495 if !file_path.is_file() {
496 continue;
497 }
498 let content = match std::fs::read_to_string(&file_path) {
499 Ok(c) => c,
500 Err(_) => continue,
501 };
502 if !content.to_lowercase().contains(&query_lower) {
503 continue;
504 }
505
506 let matches = extract_matches(&content, &query_lower, scope);
507 for m in matches {
508 if !found {
509 found = true;
510 }
511 println!(
512 "[id:{} {} {}:{}]",
513 id_str,
514 label,
515 entry.name,
516 m.line_number.unwrap_or(0)
517 );
518 println!("{}", m.text);
519 println!();
520 }
521 }
522 }
523
524 if !found {
525 println!("No matches found for '{query}'.");
526 }
527
528 Ok(())
529}
530
531struct MatchFragment {
532 text: String,
533 line_number: Option<usize>,
534}
535
536fn extract_matches(content: &str, query_lower: &str, scope: SearchScope) -> Vec<MatchFragment> {
537 match scope {
538 SearchScope::Sentence => extract_sentences(content, query_lower),
539 SearchScope::Paragraph => extract_paragraphs(content, query_lower),
540 SearchScope::Section => extract_sections(content, query_lower),
541 SearchScope::File => extract_file(content, query_lower),
542 }
543}
544
545fn extract_sentences(content: &str, query_lower: &str) -> Vec<MatchFragment> {
546 let mut results = Vec::new();
547 let line_starts: Vec<usize> = std::iter::once(0)
549 .chain(content.match_indices('\n').map(|(i, _)| i + 1))
550 .collect();
551
552 let find_line = |byte_offset: usize| -> usize {
553 match line_starts.binary_search(&byte_offset) {
554 Ok(i) => i + 1,
555 Err(i) => i,
556 }
557 };
558
559 let chars: Vec<char> = content.chars().collect();
561 let mut byte_pos = 0;
562 let mut sentence_start_byte = 0;
563
564 for (i, &ch) in chars.iter().enumerate() {
565 let ch_len = ch.len_utf8();
566 if (ch == '.' || ch == '!' || ch == '?')
567 && (i + 1 >= chars.len() || chars[i + 1].is_whitespace())
568 {
569 let sentence_end_byte = byte_pos + ch_len;
570 let sentence = &content[sentence_start_byte..sentence_end_byte];
571 if sentence.to_lowercase().contains(query_lower) {
572 results.push(MatchFragment {
573 text: sentence.trim().to_string(),
574 line_number: Some(find_line(sentence_start_byte)),
575 });
576 }
577 sentence_start_byte = sentence_end_byte;
578 }
579 byte_pos += ch_len;
580 }
581
582 if sentence_start_byte < content.len() {
584 let sentence = &content[sentence_start_byte..];
585 if sentence.to_lowercase().contains(query_lower) {
586 results.push(MatchFragment {
587 text: sentence.trim().to_string(),
588 line_number: Some(find_line(sentence_start_byte)),
589 });
590 }
591 }
592
593 results
594}
595
596fn extract_paragraphs(content: &str, query_lower: &str) -> Vec<MatchFragment> {
597 let mut results = Vec::new();
598 let mut line_num = 1;
599
600 for paragraph in content.split("\n\n") {
601 if paragraph.to_lowercase().contains(query_lower) {
602 results.push(MatchFragment {
603 text: paragraph.trim().to_string(),
604 line_number: Some(line_num),
605 });
606 }
607 line_num += paragraph.matches('\n').count() + 2;
609 }
610
611 results
612}
613
614fn extract_sections(content: &str, query_lower: &str) -> Vec<MatchFragment> {
615 let mut results = Vec::new();
616 let mut sections: Vec<(usize, String)> = Vec::new();
617
618 let mut current_start_line = 1;
619 let mut current_section = String::new();
620 let mut line_num = 0;
621
622 for line in content.lines() {
623 line_num += 1;
624 if line.starts_with("## ") && !current_section.is_empty() {
625 sections.push((current_start_line, current_section.clone()));
626 current_section.clear();
627 current_start_line = line_num;
628 }
629 if !current_section.is_empty() {
630 current_section.push('\n');
631 }
632 current_section.push_str(line);
633 }
634 if !current_section.is_empty() {
635 sections.push((current_start_line, current_section));
636 }
637
638 for (start_line, section) in sections {
639 if section.to_lowercase().contains(query_lower) {
640 results.push(MatchFragment {
641 text: section.trim().to_string(),
642 line_number: Some(start_line),
643 });
644 }
645 }
646
647 results
648}
649
650fn extract_file(content: &str, query_lower: &str) -> Vec<MatchFragment> {
651 if content.to_lowercase().contains(query_lower) {
652 vec![MatchFragment {
653 text: content.trim().to_string(),
654 line_number: Some(1),
655 }]
656 } else {
657 vec![]
658 }
659}
660
661pub struct MemoryCollector {
667 pub global_shared_dir: Option<PathBuf>,
668 pub global_workflow_dir: Option<PathBuf>,
669 pub cwd_memory_dir: Option<PathBuf>,
670 pub workflow_mode: MemoryMode,
672 pub local_enabled: bool,
674 pub disabled: bool,
676}
677
678impl MemoryCollector {
679 pub fn from_env(
681 workflow_name: &str,
682 workflow_mode: MemoryMode,
683 config: &ZigConfig,
684 disabled: bool,
685 ) -> Self {
686 Self {
687 global_shared_dir: paths::global_shared_memory_dir(),
688 global_workflow_dir: paths::global_memory_for(workflow_name),
689 cwd_memory_dir: paths::cwd_memory_dir(),
690 workflow_mode,
691 local_enabled: config.memory.local,
692 disabled,
693 }
694 }
695
696 pub fn collect_for_step(
700 &self,
701 step_memory: Option<&str>,
702 ) -> Result<Vec<(PathBuf, String, MemoryEntry)>, ZigError> {
703 if self.disabled {
704 return Ok(Vec::new());
705 }
706
707 let effective_mode = if step_memory.is_some() {
709 MemoryMode::from_str_opt(step_memory)
710 } else {
711 self.workflow_mode
712 };
713
714 if effective_mode == MemoryMode::None {
715 return Ok(Vec::new());
716 }
717
718 let mut entries = Vec::new();
719 let include_local = effective_mode == MemoryMode::All && self.local_enabled;
720
721 if let Some(dir) = self.global_shared_dir.as_deref() {
723 collect_from_dir(dir, &mut entries)?;
724 }
725
726 if let Some(dir) = self.global_workflow_dir.as_deref() {
728 collect_from_dir(dir, &mut entries)?;
729 }
730
731 if include_local {
733 if let Some(dir) = self.cwd_memory_dir.as_deref() {
734 collect_from_dir(dir, &mut entries)?;
735 }
736 }
737
738 Ok(entries)
739 }
740}
741
742fn collect_from_dir(
743 dir: &Path,
744 out: &mut Vec<(PathBuf, String, MemoryEntry)>,
745) -> Result<(), ZigError> {
746 if !dir.is_dir() {
747 return Ok(());
748 }
749 let manifest = load_manifest(dir)?;
750 for (id_str, entry) in &manifest.entries {
751 let abs_path = dir.join(&entry.file);
752 if abs_path.is_file() {
753 out.push((abs_path, id_str.clone(), entry.clone()));
754 }
755 }
756 Ok(())
757}
758
759pub fn render_memory_block(
763 entries: &[(PathBuf, String, MemoryEntry)],
764 workflow_name: &str,
765 step_name: Option<&str>,
766) -> String {
767 if entries.is_empty() {
768 return String::new();
769 }
770
771 let mut out = String::from("<memory>\n");
772 out.push_str(
773 "You have access to the following memory files — a scratch pad of accumulated knowledge. \
774 Read them with your file tools when relevant.\n",
775 );
776
777 let step_flag = step_name
779 .map(|s| format!(" --step {s}"))
780 .unwrap_or_default();
781 out.push_str(&format!(
782 "To add new memories: `zig memory add <path> --workflow {workflow_name}{step_flag}`\n"
783 ));
784 out.push_str(
785 "To update metadata: `zig memory update <id> --description \"...\" --tags \"...\"`\n\n",
786 );
787
788 for (path, id, entry) in entries {
789 out.push_str("- ");
790 out.push_str(&path.display().to_string());
791 if let Some(desc) = &entry.description {
792 out.push_str(&format!(" (id: {id}) — {desc}"));
793 } else {
794 out.push_str(&format!(
795 " (id: {id}, no description — run: zig memory update {id} --description \"...\")"
796 ));
797 }
798 if !entry.tags.is_empty() {
799 out.push_str(&format!(" [{}]", entry.tags.join(", ")));
800 }
801 out.push('\n');
802 }
803 out.push_str("</memory>\n\n");
804 out
805}
806
807#[cfg(test)]
808#[path = "memory_tests.rs"]
809mod tests;