Skip to main content

agent_first_mail/store/
cases.rs

1use super::*;
2
3#[derive(Debug, Clone)]
4pub(super) struct ArchivedCaseEntry {
5    pub(super) case_uid: String,
6    pub(super) path: PathBuf,
7}
8
9pub(super) fn case_name(case_path: &Path) -> Result<String> {
10    Ok(read_case_file(case_path)?.collection_name)
11}
12
13pub(super) fn update_case_name(case_path: &Path, case_name: &str) -> Result<()> {
14    let mut case = read_case_file(case_path)?;
15    case.collection_name = case_name.to_string();
16    case.updated_rfc3339 = Some(now_rfc3339());
17    write_case_file(case_path, &case)
18}
19
20pub(super) fn update_case_archive_state(case_path: &Path, status: &str) -> Result<()> {
21    let mut case = read_case_file(case_path)?;
22    case.status = status.to_string();
23    case.updated_rfc3339 = Some(now_rfc3339());
24    if status == "archived" {
25        case.archived_rfc3339.get_or_insert_with(now_rfc3339);
26    } else {
27        case.archived_rfc3339 = None;
28    }
29    write_case_file(case_path, &case)
30}
31
32pub(super) fn new_case_file(
33    case_uid: &str,
34    case_name: &str,
35    items: Vec<MessageCollectionItem>,
36) -> CaseFrontmatter {
37    let now = now_rfc3339();
38    let mut case = CaseFrontmatter::new_case(case_uid, case_name, &now);
39    case.items = items;
40    case.message_count = case.items.len();
41    if !case.items.is_empty() {
42        case.last_message_rfc3339 = Some(now);
43    }
44    case
45}
46
47pub(super) fn new_notes_md(root: &Path, language: TemplateLanguage) -> Result<String> {
48    render_template(
49        root,
50        language,
51        TemplateKey::NotesDefault,
52        &json!({"language": language.as_str()}),
53    )
54}
55
56pub(super) fn merge_case_notes(
57    root: &Path,
58    language: TemplateLanguage,
59    case_uid: &str,
60    primary: &Path,
61    other: &Path,
62    other_case_uid: &str,
63) -> Result<()> {
64    let other_notes_path = other.join("notes.md");
65    if !other_notes_path.exists() {
66        return Ok(());
67    }
68    let other_notes = read_to_string(&other_notes_path, "read merged notes.md")?;
69    if other_notes.trim().is_empty() {
70        return Ok(());
71    }
72    let primary_notes_path = primary.join("notes.md");
73    if !primary_notes_path.exists() {
74        return Err(notes_missing_error(root, &primary_notes_path));
75    }
76    let existing = read_to_string(&primary_notes_path, "read primary notes.md")?;
77    let section = render_template(
78        root,
79        language,
80        TemplateKey::NotesMergeSection,
81        &json!({
82            "language": language.as_str(),
83            "case_uid": case_uid,
84            "other_case_uid": other_case_uid,
85            "other_body": other_notes,
86        }),
87    )?;
88    let merged = format!("{}\n\n{}", existing.trim_end(), section.trim_start());
89    write_string(&primary_notes_path, &merged)
90}
91
92pub(super) fn update_case_counts(
93    case: &mut CaseFrontmatter,
94    added_ids: &[String],
95    attachment_count: Option<usize>,
96) {
97    case.message_count += added_ids.len();
98    case.updated_rfc3339 = Some(now_rfc3339());
99    if let Some(count) = attachment_count {
100        case.attachment_count = count;
101    }
102}
103
104pub(super) fn read_case_messages(case_path: &Path, case_uid: &str) -> Result<CaseMessages> {
105    let messages = read_case_file(case_path)?;
106    if messages.collection_uid != case_uid {
107        return Err(AppError::new(
108            "case_messages_invalid",
109            format!(
110                "invalid case uid in metadata: {}",
111                path_to_string(&case_json_path(case_path))
112            ),
113        ));
114    }
115    Ok(messages)
116}
117
118pub(super) fn existing_triage_suggestion(path: &Path) -> Result<(Vec<String>, Option<String>)> {
119    if !path.exists() {
120        return Ok((Vec::new(), None));
121    }
122    let text = read_to_string(path, "read triage file")?;
123    let (fm, _) = read_doc::<TriageFrontmatter>(&text)?;
124    if fm.suggested_case_uids.is_empty() {
125        return Ok((Vec::new(), None));
126    }
127    Ok((fm.suggested_case_uids, fm.suggested_reason))
128}
129
130impl Workspace {
131    pub fn create_case(
132        &self,
133        name: &str,
134        group: Option<&str>,
135        message_id: Option<&str>,
136        summary: Option<&str>,
137        reason: Option<&str>,
138    ) -> Result<Value> {
139        self.require_workspace()?;
140        let reason = if message_id.is_some() {
141            self.checked_reason(reason)?
142        } else {
143            reason.map(str::trim).filter(|value| !value.is_empty())
144        };
145        validate_name("case_name", name)?;
146        if let Some(message_id) = message_id {
147            validate_id("message_id", message_id)?;
148        }
149        let config = MailConfig::load(&self.root)?;
150        let group = group.unwrap_or(config.case.default_group.as_str());
151        validate_id("group", group)?;
152        let date = if let Some(message_id) = message_id {
153            self.first_related_message_date(message_id)?
154        } else {
155            workspace_local_date(&config.resolved_timezone_offset())
156        };
157        let case_uid = self.next_case_uid(&date)?;
158        let case_path = self
159            .root
160            .join("cases")
161            .join(group)
162            .join(case_dir_name(&case_uid, name));
163        if case_path.exists() {
164            return Err(AppError::new(
165                "case_exists",
166                format!("case path already exists: {}", path_to_string(&case_path)),
167            ));
168        }
169        create_dir_all(&case_data_dir(&case_path))?;
170        create_dir_all(&case_views_messages_dir(&case_path))?;
171        create_dir_all(&case_path.join("drafts"))?;
172        create_dir_all(&case_path.join("files"))?;
173        let added_rfc3339 = now_rfc3339();
174        let items = message_id
175            .map(|message_id| {
176                vec![MessageCollectionItem {
177                    message_id: message_id.to_string(),
178                    summary: summary
179                        .or(reason)
180                        .map(str::trim)
181                        .filter(|value| !value.is_empty())
182                        .map(ToString::to_string),
183                    added_rfc3339: added_rfc3339.clone(),
184                }]
185            })
186            .unwrap_or_default();
187        let case_data = new_case_file(&case_uid, name, items);
188        write_case_file(&case_path, &case_data)?;
189        write_string_new(
190            &case_path.join("notes.md"),
191            &new_notes_md(&self.root, config.template_language())?,
192        )?;
193        let message_ids = case_data.message_ids();
194        if !message_ids.is_empty() {
195            for message_id in &message_ids {
196                self.clear_message_from_all_dispositions(message_id)?;
197            }
198            self.refresh_messages_after_ref_change(&message_ids)?;
199        }
200        self.refresh_case_message_views(&case_path)?;
201        let mut result = json!({
202            "code": "case_created",
203            "case_uid": case_uid,
204            "case_name": name,
205            "group": group,
206            "message_ids": message_ids,
207            "message_count": case_data.message_count,
208            "case_path": rel_path(&self.root, &case_path)
209        });
210        if !message_ids.is_empty() {
211            let locations = self.message_remote_locations_any(&message_ids)?;
212            let item = crate::push_queue::queue_action_steps(
213                &self.root,
214                "case.add",
215                &message_ids,
216                &locations,
217                &config.actions.case_add.steps,
218                None,
219            )?;
220            if let Some(item) = &item {
221                self.record_pending_push_item(item)?;
222            }
223            add_queue_fields(&mut result, locations.len(), item.as_ref());
224        }
225        self.append_audit_event(
226            "case_created",
227            vec![audit_target("case", &case_uid)],
228            reason,
229            json!({
230                "case_uid": case_uid,
231                "case_name": name,
232                "group": group,
233                "message_ids": message_ids,
234                "summary": summary,
235                "case_path": rel_path(&self.root, &case_path),
236            }),
237        )?;
238        Ok(result)
239    }
240
241    pub fn add_message_to_case(
242        &self,
243        case_ref: &str,
244        message_id: &str,
245        summary: Option<&str>,
246        reason: Option<&str>,
247    ) -> Result<Value> {
248        self.require_workspace()?;
249        let reason = self.checked_reason(reason)?;
250        let case_uid = parse_case_ref(case_ref)?;
251        validate_id("message_id", message_id)?;
252        let existing = self.find_case_by_uid(&case_uid)?;
253        if existing.is_none() && self.find_archived_case_by_uid(&case_uid)?.is_some() {
254            return Err(case_archived_error(&case_uid));
255        }
256        let case_path = existing.ok_or_else(|| {
257            AppError::new("case_not_found", format!("case not found: {case_uid}"))
258        })?;
259        let mut result = self.add_message_to_existing_case(
260            &case_uid,
261            message_id,
262            summary.or(reason),
263            &case_path,
264        )?;
265        let config = MailConfig::load(&self.root)?;
266        let message_ids = vec![message_id.to_string()];
267        let locations = self.message_remote_locations_any(&message_ids)?;
268        let item = crate::push_queue::queue_action_steps(
269            &self.root,
270            "case.add",
271            &message_ids,
272            &locations,
273            &config.actions.case_add.steps,
274            None,
275        )?;
276        if let Some(item) = &item {
277            self.record_pending_push_item(item)?;
278        }
279        add_queue_fields(&mut result, locations.len(), item.as_ref());
280        self.append_audit_event(
281            "case_message_added",
282            vec![
283                audit_target("case", &case_uid),
284                audit_target("message", message_id),
285            ],
286            reason,
287            json!({
288                "case_uid": case_uid,
289                "message_id": message_id,
290                "summary": summary,
291                "group": result.get("group").and_then(Value::as_str).unwrap_or_default(),
292            }),
293        )?;
294        Ok(result)
295    }
296
297    pub(super) fn add_message_to_existing_case(
298        &self,
299        case_uid: &str,
300        message_id: &str,
301        summary: Option<&str>,
302        case_path: &Path,
303    ) -> Result<Value> {
304        let related_message_ids = self.related_message_ids(message_id)?;
305        let mut case = read_case_file(case_path)?;
306        let already_present = case.contains_message(message_id);
307        if !already_present {
308            let added_rfc3339 = now_rfc3339();
309            case.upsert_item(message_id, summary, &added_rfc3339);
310            update_case_counts(&mut case, &[message_id.to_string()], None);
311            write_case_file(case_path, &case)?;
312        } else if summary
313            .map(str::trim)
314            .filter(|value| !value.is_empty())
315            .is_some()
316        {
317            let added_rfc3339 = now_rfc3339();
318            case.upsert_item(message_id, summary, &added_rfc3339);
319            case.updated_rfc3339 = Some(added_rfc3339);
320            write_case_file(case_path, &case)?;
321        }
322        self.clear_message_from_all_dispositions(message_id)?;
323        self.refresh_messages_after_ref_change(&[message_id.to_string()])?;
324        self.refresh_case_message_views(case_path)?;
325        let group = case_path
326            .parent()
327            .and_then(Path::file_name)
328            .and_then(|s| s.to_str())
329            .unwrap_or_default();
330        Ok(json!({
331            "code": "case_message_added",
332            "case_uid": case_uid,
333            "message_id": message_id,
334            "group": group,
335            "created_case": false,
336            "message_count": 1,
337            "already_present": already_present,
338            "related_message_ids": related_message_ids,
339            "case_path": rel_path(&self.root, case_path)
340        }))
341    }
342
343    pub fn move_case(&self, case_ref: &str, group: &str) -> Result<Value> {
344        self.require_workspace()?;
345        validate_id("group", group)?;
346        let (case_uid, from) = self.resolve_active_case(case_ref)?;
347        let from_group = from
348            .parent()
349            .and_then(Path::file_name)
350            .and_then(|s| s.to_str())
351            .unwrap_or_default()
352            .to_string();
353        let from_parent = from.parent().map(Path::to_path_buf);
354        let dir_name = from
355            .file_name()
356            .ok_or_else(|| AppError::new("store_error", "case has no directory name"))?;
357        let to = self.root.join("cases").join(group).join(dir_name);
358        if to == from {
359            return Ok(json!({
360                "code": "case_moved",
361                "case_uid": case_uid,
362                "from_group": from_group,
363                "to_group": group,
364                "case_path": rel_path(&self.root, &to)
365            }));
366        }
367        if to.exists() {
368            return Err(AppError::new(
369                "duplicate_case_uid",
370                format!("target case path already exists: {}", path_to_string(&to)),
371            ));
372        }
373        if let Some(parent) = to.parent() {
374            create_dir_all(parent)?;
375        }
376        fs::rename(&from, &to).map_err(|e| AppError::io("move case", &e))?;
377        if let Some(parent) = from_parent {
378            self.remove_empty_case_container_dir(&parent)?;
379        }
380        self.refresh_case_message_views(&to)?;
381        Ok(json!({
382            "code": "case_moved",
383            "case_uid": case_uid,
384            "from_group": from_group,
385            "to_group": group,
386            "case_path": rel_path(&self.root, &to)
387        }))
388    }
389
390    pub(super) fn ensure_case_has_no_local_drafts(
391        &self,
392        case_uid: &str,
393        case_path: &Path,
394    ) -> Result<()> {
395        let drafts_dir = case_path.join("drafts");
396        let mut draft_names = Vec::new();
397        if drafts_dir.exists() {
398            for entry in read_dir(&drafts_dir, "read drafts")? {
399                let path = entry.path();
400                if path.extension().and_then(|s| s.to_str()) == Some("md") {
401                    draft_names.push(path_file_name(&path));
402                }
403            }
404        }
405        draft_names.sort();
406        if draft_names.is_empty() {
407            return Ok(());
408        }
409        Err(AppError::new(
410            "case_has_local_drafts",
411            format!(
412                "case {case_uid} has local drafts: {}",
413                draft_names.join(", ")
414            ),
415        )
416        .with_hint(
417            "Queue drafts with `afmail case draft save` or `afmail case draft send`, push them, or remove drafts before archive/merge.",
418        )
419        .with_details(json!({
420            "case_uid": case_uid,
421            "draft_names": draft_names,
422            "suggested_commands": [
423                format!("afmail case draft validate {case_uid} DRAFT_NAME"),
424                format!("afmail case draft save {case_uid} DRAFT_NAME"),
425                format!("afmail case draft send {case_uid} DRAFT_NAME"),
426                format!("afmail case draft remove {case_uid} DRAFT_NAME --reason TEXT")
427            ]
428        })))
429    }
430
431    pub(super) fn ensure_case_has_no_outbound_push(&self, case_uid: &str) -> Result<()> {
432        let push_dir = self.root.join(".afmail/push");
433        if !push_dir.exists() {
434            return Ok(());
435        }
436        let mut push_ids = Vec::new();
437        for entry in read_dir(&push_dir, "read push queue")? {
438            let path = entry.path();
439            if path.extension().and_then(|s| s.to_str()) != Some("json") {
440                continue;
441            }
442            let data = read_to_string(&path, "read push item")?;
443            let item = PushItem::parse_json(&data)?;
444            if item
445                .outbound()
446                .is_some_and(|outbound| outbound.case_uid == case_uid)
447            {
448                push_ids.push(item.push_id);
449            }
450        }
451        push_ids.sort();
452        if push_ids.is_empty() {
453            return Ok(());
454        }
455        Err(AppError::new(
456            "case_has_outbound_push",
457            format!(
458                "case {case_uid} has queued outbound push items: {}",
459                push_ids.join(", ")
460            ),
461        )
462        .with_hint("Push queued drafts or remove the corresponding drafts before archive/merge.")
463        .with_details(json!({
464            "case_uid": case_uid,
465            "push_ids": push_ids,
466            "suggested_commands": [
467                "afmail push --dry-run",
468                "afmail push --confirm",
469                format!("afmail case draft remove {case_uid} DRAFT_NAME --reason TEXT")
470            ]
471        })))
472    }
473
474    pub fn archive_case(&self, case_ref: &str, reason: Option<&str>) -> Result<Value> {
475        self.require_workspace()?;
476        let reason = self.checked_reason(reason)?;
477        let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
478        self.ensure_case_has_no_local_drafts(&case_uid, &case_path)?;
479        self.ensure_case_has_no_outbound_push(&case_uid)?;
480        let from_path = rel_path(&self.root, &case_path);
481        let messages = read_case_messages(&case_path, &case_uid)?;
482        let transaction = self.begin_transaction(
483            "case_archive",
484            vec![
485                from_path.clone(),
486                format!("archive/cases/{case_uid}"),
487                ".afmail/push".to_string(),
488            ],
489        )?;
490        let archived_path = self.archive_active_case_workspace(&case_uid, &case_path)?;
491        let message_ids = messages.message_ids();
492        self.refresh_messages_after_ref_change(&message_ids)?;
493        self.refresh_case_message_views(&archived_path)?;
494        let queue = self.queue_archive_for_archived_messages(&message_ids, None)?;
495        transaction.commit()?;
496        self.append_audit_event(
497            "case_archived",
498            vec![audit_target("case", &case_uid)],
499            reason,
500            json!({
501                "case_uid": case_uid,
502                "from_path": from_path,
503                "to_path": rel_path(&self.root, &archived_path),
504                "message_ids": message_ids,
505            }),
506        )?;
507        Ok(json!({
508            "code": "case_archived",
509            "case_uid": case_uid,
510            "message_count": messages.message_count,
511            "eligible_message_ids": queue.eligible_message_ids,
512            "location_count": queue.location_count,
513            "queued_location_count": queue.queued_location_count,
514            "queued": !queue.items.is_empty(),
515            "push_ids": queue.items.iter().map(|item| item.push_id.clone()).collect::<Vec<_>>(),
516            "push_id": queue.items.first().map(|item| item.push_id.clone()),
517            "from_path": from_path,
518            "case_path": rel_path(&self.root, &archived_path)
519        }))
520    }
521
522    pub fn reopen_case(&self, case_ref: &str, reason: Option<&str>) -> Result<Value> {
523        self.require_workspace()?;
524        let reason = self.checked_reason(reason)?;
525        let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
526        let messages = read_case_messages(&case_path, &case_uid)?;
527        self.set_case_status(&case_path, "active")?;
528        let message_ids = messages.message_ids();
529        self.refresh_messages_after_ref_change(&message_ids)?;
530        let result = json!({
531            "code": "case_reopened",
532            "case_uid": case_uid,
533            "status": "active",
534            "message_count": messages.message_count,
535            "case_path": rel_path(&self.root, &case_path)
536        });
537        self.append_audit_event(
538            "case_reopened",
539            vec![audit_target("case", &case_uid)],
540            reason,
541            json!({"case_uid": case_uid}),
542        )?;
543        Ok(result)
544    }
545
546    pub fn tag_case(&self, case_ref: &str, tag: &str, reason: Option<&str>) -> Result<Value> {
547        self.require_workspace()?;
548        let reason = self.checked_reason(reason)?;
549        validate_id("tag", tag)?;
550        let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
551        let tags = self.update_case_tags(&case_path, Some(tag), None)?;
552        let result = json!({
553            "code": "case_tagged",
554            "case_uid": case_uid,
555            "tag": tag,
556            "tags": tags,
557            "case_path": rel_path(&self.root, &case_path)
558        });
559        self.append_audit_event(
560            "case_tagged",
561            vec![audit_target("case", &case_uid)],
562            reason,
563            json!({"case_uid": case_uid, "tag": tag, "tags": tags}),
564        )?;
565        Ok(result)
566    }
567
568    pub fn untag_case(&self, case_ref: &str, tag: &str, reason: Option<&str>) -> Result<Value> {
569        self.require_workspace()?;
570        let reason = self.checked_reason(reason)?;
571        validate_id("tag", tag)?;
572        let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
573        let tags = self.update_case_tags(&case_path, None, Some(tag))?;
574        let result = json!({
575            "code": "case_untagged",
576            "case_uid": case_uid,
577            "tag": tag,
578            "tags": tags,
579            "case_path": rel_path(&self.root, &case_path)
580        });
581        self.append_audit_event(
582            "case_untagged",
583            vec![audit_target("case", &case_uid)],
584            reason,
585            json!({"case_uid": case_uid, "tag": tag, "tags": tags}),
586        )?;
587        Ok(result)
588    }
589
590    pub fn merge_case(
591        &self,
592        case_ref: &str,
593        other_case_ref: &str,
594        reason: Option<&str>,
595    ) -> Result<Value> {
596        self.require_workspace()?;
597        let reason = self.checked_reason(reason)?;
598        let (case_uid, primary) = self.resolve_active_case(case_ref)?;
599        let (other_case_uid, other) = self.resolve_active_case(other_case_ref)?;
600        if case_uid == other_case_uid {
601            return Err(AppError::new(
602                "invalid_request",
603                "cannot merge a case into itself",
604            ));
605        }
606        self.ensure_case_has_no_local_drafts(&case_uid, &primary)?;
607        self.ensure_case_has_no_local_drafts(&other_case_uid, &other)?;
608        self.ensure_case_has_no_outbound_push(&case_uid)?;
609        self.ensure_case_has_no_outbound_push(&other_case_uid)?;
610        ensure_no_name_conflicts(&primary.join("files"), &other.join("files"), "files")?;
611        ensure_no_name_conflicts(&primary.join("drafts"), &other.join("drafts"), "drafts")?;
612        let mut primary_messages = read_case_messages(&primary, &case_uid)?;
613        let other_messages = read_case_messages(&other, &other_case_uid)?;
614        primary_messages.merge_items(&other_messages.items);
615        primary_messages.updated_rfc3339 = Some(now_rfc3339());
616        write_case_file(&primary, &primary_messages)?;
617        merge_case_notes(
618            &self.root,
619            self.template_language()?,
620            &case_uid,
621            &primary,
622            &other,
623            &other_case_uid,
624        )?;
625        move_children(&other.join("files"), &primary.join("files"))?;
626        move_children(&other.join("drafts"), &primary.join("drafts"))?;
627        let other_parent = other.parent().map(Path::to_path_buf);
628        remove_dir_all(&other)?;
629        if let Some(parent) = other_parent {
630            self.remove_empty_case_container_dir(&parent)?;
631        }
632        let other_message_ids = other_messages.message_ids();
633        self.refresh_messages_after_ref_change(&other_message_ids)?;
634        self.refresh_case_message_views(&primary)?;
635        self.append_audit_event(
636            "case_merged",
637            vec![
638                audit_target("case", &case_uid),
639                audit_target("case", &other_case_uid),
640            ],
641            reason,
642            json!({
643                "case_uid": case_uid,
644                "merged_case_uid": other_case_uid,
645                "message_ids": other_message_ids,
646            }),
647        )?;
648        Ok(json!({
649            "code": "case_merged",
650            "case_uid": case_uid,
651            "merged_case_uid": other_case_uid,
652            "message_count": other_messages.message_count
653        }))
654    }
655
656    pub fn rename_active_case(
657        &self,
658        case_ref: &str,
659        name: &str,
660        reason: Option<&str>,
661    ) -> Result<Value> {
662        self.require_workspace()?;
663        let reason = self.checked_reason(reason)?;
664        validate_name("case_name", name)?;
665        let (case_uid, from) = self.resolve_active_case(case_ref)?;
666        let old_name = case_name(&from)?;
667        let group = from
668            .parent()
669            .and_then(Path::file_name)
670            .and_then(|s| s.to_str())
671            .unwrap_or_default()
672            .to_string();
673        let to = from
674            .parent()
675            .ok_or_else(|| AppError::new("store_error", "case has no parent directory"))?
676            .join(case_dir_name(&case_uid, name));
677        let changed_path = to != from;
678        if changed_path && to.exists() {
679            return Err(AppError::new(
680                "duplicate_case_uid",
681                format!("target case path already exists: {}", path_to_string(&to)),
682            ));
683        }
684        if changed_path {
685            fs::rename(&from, &to).map_err(|e| AppError::io("rename case", &e))?;
686        }
687        update_case_name(&to, name)?;
688        self.refresh_case_message_views(&to)?;
689        self.append_audit_event(
690            "case_renamed",
691            vec![audit_target("case", &case_uid)],
692            reason,
693            json!({
694                "case_uid": case_uid,
695                "old_case_name": old_name,
696                "case_name": name,
697                "group": group,
698                "from_path": rel_path(&self.root, &from),
699                "to_path": rel_path(&self.root, &to),
700            }),
701        )?;
702        Ok(json!({
703            "code": "case_renamed",
704            "case_uid": case_uid,
705            "old_case_name": old_name,
706            "case_name": name,
707            "group": group,
708            "case_path": rel_path(&self.root, &to),
709            "changed": old_name != name || changed_path
710        }))
711    }
712
713    pub fn active_case_show(&self, case_ref: &str) -> Result<Value> {
714        self.require_workspace()?;
715        // Reads ignore lifecycle: if the ref is not an active case, fall back to
716        // the archived case with the same stable uid.
717        let (case_uid, case_path) = match self.resolve_active_case(case_ref) {
718            Ok(resolved) => resolved,
719            Err(_) => return self.archive_case_show(case_ref),
720        };
721        self.refresh_case_message_views(&case_path)?;
722        let view_path = case_path.join("case.md");
723        let text = read_to_string(&view_path, "read active case")?;
724        let case = read_case_file(&case_path)?;
725        let group = case_path
726            .parent()
727            .and_then(Path::file_name)
728            .and_then(|s| s.to_str())
729            .unwrap_or_default()
730            .to_string();
731        Ok(json!({
732            "code": "case",
733            "case_uid": case_uid,
734            "case_name": case.collection_name,
735            "group": group,
736            "case_path": rel_path(&self.root, &case_path),
737            "view_path": rel_path(&self.root, &view_path),
738            "messages_path": rel_path(&self.root, &case_views_messages_dir(&case_path)),
739            "text": text,
740        }))
741    }
742
743    pub fn case_list(&self) -> Result<Value> {
744        self.require_workspace()?;
745        let items = self.active_case_items()?;
746        Ok(json!({
747            "code": "case_list",
748            "count": items.len(),
749            "path_templates": {
750                "case_path": "cases/{group}/{case_dir}",
751                "view_path": "cases/{group}/{case_dir}/case.md",
752                "data_path": "cases/{group}/{case_dir}/data/case.json",
753            },
754            "items": items,
755        }))
756    }
757
758    pub fn active_case_notes_show(&self, case_ref: &str) -> Result<Value> {
759        // Reads ignore lifecycle: fall back to the archived case's notes.
760        let (case_uid, case_path) = match self.resolve_active_case(case_ref) {
761            Ok(resolved) => resolved,
762            Err(_) => return self.archive_case_notes_show(case_ref),
763        };
764        self.notes_show(
765            "case_notes",
766            vec![audit_target("case", &case_uid)],
767            &case_path.join("notes.md"),
768        )
769    }
770
771    pub fn active_case_notes_append(&self, case_ref: &str, text: &str) -> Result<Value> {
772        let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
773        self.notes_append(
774            "case_notes_appended",
775            vec![audit_target("case", &case_uid)],
776            &case_path.join("notes.md"),
777            text,
778        )
779    }
780
781    pub fn active_case_notes_replace(&self, case_ref: &str, text: &str) -> Result<Value> {
782        let (case_uid, case_path) = self.resolve_active_case(case_ref)?;
783        self.notes_replace(
784            "case_notes_replaced",
785            vec![audit_target("case", &case_uid)],
786            &case_path.join("notes.md"),
787            text,
788        )
789    }
790
791    pub fn archive_case_show(&self, case_ref: &str) -> Result<Value> {
792        self.require_workspace()?;
793        let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
794        self.refresh_case_message_views(&entry.path)?;
795        let path = entry.path.join("case.md");
796        let text = read_to_string(&path, "read archived case")?;
797        let name = case_name(&entry.path)?;
798        Ok(json!({
799            "code": "archive_case",
800            "case_uid": case_uid,
801            "case_name": name,
802            "case_path": rel_path(&self.root, &entry.path),
803            "view_path": rel_path(&self.root, &path),
804            "text": text,
805        }))
806    }
807
808    pub fn archive_case_restore(
809        &self,
810        case_ref: &str,
811        group: &str,
812        reason: Option<&str>,
813    ) -> Result<Value> {
814        self.require_workspace()?;
815        let reason = self.checked_reason(reason)?;
816        validate_id("group", group)?;
817        let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
818        let dir_name = entry
819            .path
820            .file_name()
821            .ok_or_else(|| AppError::new("store_error", "case has no directory name"))?;
822        let active_path = self.root.join("cases").join(group).join(dir_name);
823        if active_path.exists() {
824            return Err(AppError::new(
825                "case_exists",
826                format!(
827                    "active case path already exists: {}",
828                    path_to_string(&active_path)
829                ),
830            ));
831        }
832        let messages = read_case_messages(&entry.path, &case_uid)?;
833        update_case_archive_state(&entry.path, "active")?;
834        if let Some(parent) = active_path.parent() {
835            create_dir_all(parent)?;
836        }
837        fs::rename(&entry.path, &active_path).map_err(|e| AppError::io("restore case", &e))?;
838        let message_ids = messages.message_ids();
839        self.refresh_messages_after_ref_change(&message_ids)?;
840        self.refresh_case_message_views(&active_path)?;
841        self.append_audit_event(
842            "case_restored",
843            vec![audit_target("case", &case_uid)],
844            reason,
845            json!({
846                "case_uid": case_uid,
847                "to_group": group,
848                "from_path": rel_path(&self.root, &entry.path),
849                "to_path": rel_path(&self.root, &active_path),
850            }),
851        )?;
852        Ok(json!({
853            "code": "case_restored",
854            "case_uid": case_uid,
855            "group": group,
856            "case_path": rel_path(&self.root, &active_path)
857        }))
858    }
859
860    pub fn archive_case_rename(
861        &self,
862        case_ref: &str,
863        name: &str,
864        reason: Option<&str>,
865    ) -> Result<Value> {
866        self.require_workspace()?;
867        let reason = self.checked_reason(reason)?;
868        validate_name("case_name", name)?;
869        let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
870        let old_name = case_name(&entry.path)?;
871        let to = self.archive_case_path_for_name(&case_uid, name);
872        let changed_path = to != entry.path;
873        if changed_path && to.exists() {
874            return Err(AppError::new(
875                "duplicate_case_uid",
876                format!(
877                    "target archived case path already exists: {}",
878                    path_to_string(&to)
879                ),
880            ));
881        }
882        if let Some(parent) = to.parent() {
883            create_dir_all(parent)?;
884        }
885        if changed_path {
886            fs::rename(&entry.path, &to).map_err(|e| AppError::io("rename archived case", &e))?;
887        }
888        update_case_name(&to, name)?;
889        self.refresh_case_message_views(&to)?;
890        self.append_audit_event(
891            "archive_case_renamed",
892            vec![audit_target("case", &case_uid)],
893            reason,
894            json!({
895                "case_uid": case_uid,
896                "old_case_name": old_name,
897                "case_name": name,
898                "from_path": rel_path(&self.root, &entry.path),
899                "to_path": rel_path(&self.root, &to),
900            }),
901        )?;
902        Ok(json!({
903            "code": "archive_case_renamed",
904            "case_uid": case_uid,
905            "old_case_name": old_name,
906            "case_name": name,
907            "case_path": rel_path(&self.root, &to),
908            "changed": old_name != name || changed_path
909        }))
910    }
911
912    pub fn archive_case_notes_show(&self, case_ref: &str) -> Result<Value> {
913        let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
914        self.notes_show(
915            "case_notes",
916            vec![audit_target("case", &case_uid)],
917            &entry.path.join("notes.md"),
918        )
919    }
920
921    pub fn archive_case_notes_append(&self, case_ref: &str, text: &str) -> Result<Value> {
922        let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
923        self.notes_append(
924            "case_notes_appended",
925            vec![audit_target("case", &case_uid)],
926            &entry.path.join("notes.md"),
927            text,
928        )
929    }
930
931    pub fn archive_case_notes_replace(&self, case_ref: &str, text: &str) -> Result<Value> {
932        let (case_uid, entry) = self.resolve_archived_case(case_ref)?;
933        self.notes_replace(
934            "case_notes_replaced",
935            vec![audit_target("case", &case_uid)],
936            &entry.path.join("notes.md"),
937            text,
938        )
939    }
940
941    pub(super) fn archive_active_case_workspace(
942        &self,
943        case_uid: &str,
944        case_path: &Path,
945    ) -> Result<PathBuf> {
946        validate_case_uid(case_uid)?;
947        let dir_name = case_path
948            .file_name()
949            .ok_or_else(|| AppError::new("store_error", "case has no directory name"))?;
950        let archived_path = self.root.join("archive").join("cases").join(dir_name);
951        if archived_path.exists() {
952            return Err(AppError::new(
953                "case_exists",
954                format!(
955                    "archived case already exists: {}",
956                    path_to_string(&archived_path)
957                ),
958            ));
959        }
960        if let Some(parent) = archived_path.parent() {
961            create_dir_all(parent)?;
962        }
963        let source_parent = case_path.parent().map(Path::to_path_buf);
964        fs::rename(case_path, &archived_path).map_err(|e| AppError::io("archive case", &e))?;
965        update_case_archive_state(&archived_path, "archived")?;
966        if let Some(parent) = source_parent {
967            self.remove_empty_case_container_dir(&parent)?;
968        }
969        Ok(archived_path)
970    }
971
972    pub(super) fn remove_empty_case_container_dir(&self, dir: &Path) -> Result<bool> {
973        if !self.is_removable_case_container_dir(dir) {
974            return Ok(false);
975        }
976        match fs::remove_dir(dir) {
977            Ok(()) => Ok(true),
978            Err(e)
979                if matches!(
980                    e.kind(),
981                    std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
982                ) =>
983            {
984                Ok(false)
985            }
986            Err(e) => Err(AppError::io("remove empty case container directory", &e)),
987        }
988    }
989
990    pub(super) fn is_removable_case_container_dir(&self, dir: &Path) -> bool {
991        let active_cases_dir = self.root.join("cases");
992        dir.parent() == Some(active_cases_dir.as_path()) && dir != active_cases_dir
993    }
994
995    pub(super) fn active_case_items(&self) -> Result<Vec<Value>> {
996        let mut out = Vec::new();
997        for (case_uid, path) in self.case_entries()? {
998            let group = path
999                .parent()
1000                .and_then(Path::file_name)
1001                .and_then(|s| s.to_str())
1002                .unwrap_or_default()
1003                .to_string();
1004            out.push(json!({
1005                "case_uid": case_uid,
1006                "case_name": case_name(&path).unwrap_or_default(),
1007                "group": group,
1008                "case_dir": path
1009                    .file_name()
1010                    .and_then(|s| s.to_str())
1011                    .unwrap_or_default(),
1012            }));
1013        }
1014        out.sort_by(|a, b| {
1015            let a_key = (
1016                a.get("group").and_then(Value::as_str).unwrap_or_default(),
1017                a.get("case_uid")
1018                    .and_then(Value::as_str)
1019                    .unwrap_or_default(),
1020            );
1021            let b_key = (
1022                b.get("group").and_then(Value::as_str).unwrap_or_default(),
1023                b.get("case_uid")
1024                    .and_then(Value::as_str)
1025                    .unwrap_or_default(),
1026            );
1027            a_key.cmp(&b_key)
1028        });
1029        Ok(out)
1030    }
1031
1032    pub(super) fn archive_case_items(&self) -> Result<Vec<Value>> {
1033        Ok(self
1034            .archived_case_entries()?
1035            .into_iter()
1036            .map(|entry| {
1037                json!({
1038                    "case_uid": entry.case_uid,
1039                    "case_name": case_name(&entry.path).unwrap_or_default(),
1040                    "case_dir": entry
1041                        .path
1042                        .file_name()
1043                        .and_then(|s| s.to_str())
1044                        .unwrap_or_default(),
1045                })
1046            })
1047            .collect())
1048    }
1049
1050    pub(super) fn notes_show(&self, code: &str, targets: Vec<Value>, path: &Path) -> Result<Value> {
1051        let text = read_existing_notes(&self.root, path)?;
1052        Ok(json!({
1053            "code": code,
1054            "targets": targets,
1055            "notes_path": rel_path(&self.root, path),
1056            "text": text
1057        }))
1058    }
1059
1060    pub(super) fn notes_append(
1061        &self,
1062        kind: &str,
1063        targets: Vec<Value>,
1064        path: &Path,
1065        text: &str,
1066    ) -> Result<Value> {
1067        let mut existing = read_existing_notes(&self.root, path)?;
1068        if !existing.ends_with('\n') {
1069            existing.push('\n');
1070        }
1071        existing.push_str(text);
1072        if !existing.ends_with('\n') {
1073            existing.push('\n');
1074        }
1075        write_string(path, &existing)?;
1076        self.append_audit_event(
1077            kind,
1078            targets.clone(),
1079            None,
1080            json!({
1081                "operation": "append",
1082                "notes_path": rel_path(&self.root, path),
1083                "text_len_bytes": text.len(),
1084                "text_hash": stable_text_hash(text),
1085            }),
1086        )?;
1087        Ok(json!({
1088            "code": kind,
1089            "targets": targets,
1090            "notes_path": rel_path(&self.root, path),
1091            "text_len_bytes": text.len(),
1092            "text_hash": stable_text_hash(text)
1093        }))
1094    }
1095
1096    pub(super) fn notes_replace(
1097        &self,
1098        kind: &str,
1099        targets: Vec<Value>,
1100        path: &Path,
1101        text: &str,
1102    ) -> Result<Value> {
1103        let mut data = text.to_string();
1104        if !data.ends_with('\n') {
1105            data.push('\n');
1106        }
1107        write_string(path, &data)?;
1108        self.append_audit_event(
1109            kind,
1110            targets.clone(),
1111            None,
1112            json!({
1113                "operation": "replace",
1114                "notes_path": rel_path(&self.root, path),
1115                "text_len_bytes": text.len(),
1116                "text_hash": stable_text_hash(text),
1117            }),
1118        )?;
1119        Ok(json!({
1120            "code": kind,
1121            "targets": targets,
1122            "notes_path": rel_path(&self.root, path),
1123            "text_len_bytes": text.len(),
1124            "text_hash": stable_text_hash(text)
1125        }))
1126    }
1127
1128    pub fn find_case_required(&self, case_ref: &str) -> Result<PathBuf> {
1129        self.resolve_active_case(case_ref).map(|(_, path)| path)
1130    }
1131
1132    pub(super) fn resolve_active_case(&self, case_ref: &str) -> Result<(String, PathBuf)> {
1133        let case_uid = parse_case_ref(case_ref)?;
1134        if let Some(path) = self.find_case_by_uid(&case_uid)? {
1135            return Ok((case_uid, path));
1136        }
1137        if self.find_archived_case_by_uid(&case_uid)?.is_some() {
1138            return Err(case_archived_error(&case_uid));
1139        }
1140        Err(AppError::new(
1141            "case_not_found",
1142            format!("case not found: {case_uid}"),
1143        ))
1144    }
1145
1146    pub(super) fn resolve_archived_case(
1147        &self,
1148        case_ref: &str,
1149    ) -> Result<(String, ArchivedCaseEntry)> {
1150        let case_uid = parse_case_ref(case_ref)?;
1151        self.find_archived_case_by_uid(&case_uid)?
1152            .map(|entry| (case_uid.clone(), entry))
1153            .ok_or_else(|| {
1154                AppError::new(
1155                    "case_not_found",
1156                    format!("archived case not found: {case_uid}"),
1157                )
1158            })
1159    }
1160
1161    pub fn find_case(&self, case_ref: &str) -> Result<Option<PathBuf>> {
1162        let case_uid = parse_case_ref(case_ref)?;
1163        self.find_case_by_uid(&case_uid)
1164    }
1165
1166    pub(super) fn find_case_by_uid(&self, case_uid: &str) -> Result<Option<PathBuf>> {
1167        validate_case_uid(case_uid)?;
1168        let mut matches = Vec::new();
1169        for (id, path) in self.case_entries()? {
1170            if id == case_uid {
1171                matches.push(path);
1172            }
1173        }
1174        match matches.len() {
1175            0 => Ok(None),
1176            1 => Ok(matches.into_iter().next()),
1177            _ => Err(AppError::new(
1178                "duplicate_case_uid",
1179                format!("duplicate case uid found: {case_uid}"),
1180            )),
1181        }
1182    }
1183
1184    pub(super) fn find_archived_case_by_uid(
1185        &self,
1186        case_uid: &str,
1187    ) -> Result<Option<ArchivedCaseEntry>> {
1188        validate_case_uid(case_uid)?;
1189        let mut matches = Vec::new();
1190        for entry in self.archived_case_entries()? {
1191            if entry.case_uid == case_uid {
1192                matches.push(entry);
1193            }
1194        }
1195        match matches.len() {
1196            0 => Ok(None),
1197            1 => Ok(matches.into_iter().next()),
1198            _ => Err(AppError::new(
1199                "duplicate_case_uid",
1200                format!("duplicate archived case uid found: {case_uid}"),
1201            )),
1202        }
1203    }
1204
1205    pub(super) fn case_entries(&self) -> Result<Vec<(String, PathBuf)>> {
1206        let cases_dir = self.root.join("cases");
1207        if !cases_dir.exists() {
1208            return Ok(Vec::new());
1209        }
1210        let mut out = Vec::new();
1211        for group_entry in read_dir(&cases_dir, "read cases directory")? {
1212            let group_path = group_entry.path();
1213            if !group_path.is_dir() {
1214                continue;
1215            }
1216            for case_entry in read_dir(&group_path, "read case group")? {
1217                let case_path = case_entry.path();
1218                if !case_path.is_dir() || !case_json_path(&case_path).is_file() {
1219                    continue;
1220                }
1221                let fm = read_case_file(&case_path)?;
1222                out.push((fm.collection_uid, case_path));
1223            }
1224        }
1225        Ok(out)
1226    }
1227
1228    pub(super) fn all_case_entries(&self) -> Result<Vec<(String, PathBuf)>> {
1229        let mut out = self.case_entries()?;
1230        out.extend(
1231            self.archived_case_entries()?
1232                .into_iter()
1233                .map(|entry| (entry.case_uid, entry.path)),
1234        );
1235        Ok(out)
1236    }
1237
1238    pub(super) fn archived_case_entries(&self) -> Result<Vec<ArchivedCaseEntry>> {
1239        let cases_dir = self.root.join("archive/cases");
1240        if !cases_dir.exists() {
1241            return Ok(Vec::new());
1242        }
1243        let mut out = Vec::new();
1244        for case_entry in read_dir(&cases_dir, "read archived cases")? {
1245            let case_path = case_entry.path();
1246            if !case_path.is_dir() || !case_json_path(&case_path).is_file() {
1247                continue;
1248            }
1249            let fm = read_case_file(&case_path)?;
1250            out.push(ArchivedCaseEntry {
1251                case_uid: fm.collection_uid,
1252                path: case_path,
1253            });
1254        }
1255        out.sort_by(|a, b| a.case_uid.cmp(&b.case_uid));
1256        Ok(out)
1257    }
1258
1259    pub(super) fn set_case_status(&self, case_path: &Path, status: &str) -> Result<()> {
1260        let mut fm = read_case_file(case_path)?;
1261        fm.status = status.to_string();
1262        fm.updated_rfc3339 = Some(now_rfc3339());
1263        if status != "archived" {
1264            fm.archived_rfc3339 = None;
1265        }
1266        write_case_file(case_path, &fm)
1267    }
1268
1269    pub(super) fn update_case_tags(
1270        &self,
1271        case_path: &Path,
1272        add_tag: Option<&str>,
1273        remove_tag: Option<&str>,
1274    ) -> Result<Vec<String>> {
1275        let mut fm = read_case_file(case_path)?;
1276        if let Some(tag) = add_tag {
1277            merge_string(&mut fm.tags, tag);
1278        }
1279        if let Some(tag) = remove_tag {
1280            fm.tags.retain(|item| item != tag);
1281        }
1282        fm.updated_rfc3339 = Some(now_rfc3339());
1283        let tags = fm.tags.clone();
1284        write_case_file(case_path, &fm)?;
1285        Ok(tags)
1286    }
1287}