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